diff --git a/README.md b/README.md index 8f4e4adf..925dee45 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n | **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` | | **amazon** | `bestsellers` `search` `product` `offer` `discussion` | +| **gemini** | `new` `ask` `image` | | **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | | **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | diff --git a/README.zh-CN.md b/README.zh-CN.md index e5923f78..8f2943ce 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -177,6 +177,7 @@ npm install -g @jackwener/opencli@latest | **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 | | **google** | `news` `search` `suggest` `trends` | 公开 | | **amazon** | `bestsellers` `search` `product` `offer` `discussion` | 浏览器 | +| **gemini** | `new` `ask` `image` | 浏览器 | | **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API | | **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 浏览器 | | **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 231be21c..45da8876 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -73,6 +73,7 @@ export default defineConfig({ { text: 'Chaoxing', link: '/adapters/browser/chaoxing' }, { text: 'Grok', link: '/adapters/browser/grok' }, { text: 'Amazon', link: '/adapters/browser/amazon' }, + { text: 'Gemini', link: '/adapters/browser/gemini' }, { text: 'NotebookLM', link: '/adapters/browser/notebooklm' }, { text: 'WeRead', link: '/adapters/browser/weread' }, { text: 'Douban', link: '/adapters/browser/douban' }, diff --git a/docs/adapters/browser/gemini.md b/docs/adapters/browser/gemini.md new file mode 100644 index 00000000..a1799f27 --- /dev/null +++ b/docs/adapters/browser/gemini.md @@ -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 ` | Send a prompt and return only the assistant reply | +| `opencli gemini image ` | 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. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 1835a1e6..5b5b4cc2 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -29,6 +29,7 @@ Run `opencli list` for the live registry. | **[linux-do](./browser/linux-do)** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 🔐 Browser | | **[chaoxing](./browser/chaoxing)** | `assignments` `exams` | 🔐 Browser | | **[grok](./browser/grok)** | `ask` | 🔐 Browser | +| **[gemini](./browser/gemini)** | `new` `ask` `image` | 🔐 Browser | | **[notebooklm](./browser/notebooklm)** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 🔐 Browser | | **[doubao](./browser/doubao)** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 🔐 Browser | | **[weread](./browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser | diff --git a/src/clis/gemini/ask.ts b/src/clis/gemini/ask.ts new file mode 100644 index 00000000..8451f5fe --- /dev/null +++ b/src/clis/gemini/ask.ts @@ -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}` }]; + }, +}); diff --git a/src/clis/gemini/image.ts b/src/clis/gemini/image.ts new file mode 100644 index 00000000..2ed03822 --- /dev/null +++ b/src/clis/gemini/image.ts @@ -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 { + 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; + }, +}); diff --git a/src/clis/gemini/new.ts b/src/clis/gemini/new.ts new file mode 100644 index 00000000..9978a35f --- /dev/null +++ b/src/clis/gemini/new.ts @@ -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', + }]; + }, +}); diff --git a/src/clis/gemini/utils.test.ts b/src/clis/gemini/utils.test.ts new file mode 100644 index 00000000..4dae9c9e --- /dev/null +++ b/src/clis/gemini/utils.test.ts @@ -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'); + }); +}); diff --git a/src/clis/gemini/utils.ts b/src/clis/gemini/utils.ts new file mode 100644 index 00000000..d02db7e8 --- /dev/null +++ b/src/clis/gemini/utils.ts @@ -0,0 +1,523 @@ +import type { IPage } from '../../types.js'; + +export const GEMINI_DOMAIN = 'gemini.google.com'; +export const GEMINI_APP_URL = 'https://gemini.google.com/app'; + +export interface GeminiPageState { + url: string; + title: string; + isSignedIn: boolean | null; + composerLabel: string; + canSend: boolean; +} + +export interface GeminiTurn { + Role: 'User' | 'Assistant' | 'System'; + Text: string; +} + +const GEMINI_RESPONSE_NOISE_PATTERNS = [ + /Gemini can make mistakes\.?/gi, + /Google Terms/gi, + /Google Privacy Policy/gi, + /Opens in a new window/gi, +]; + +export function sanitizeGeminiResponseText(value: string, promptText: string): string { + let sanitized = value; + for (const pattern of GEMINI_RESPONSE_NOISE_PATTERNS) { + sanitized = sanitized.replace(pattern, ''); + } + sanitized = sanitized.trim(); + + const prompt = promptText.trim(); + if (!prompt) return sanitized; + if (sanitized === prompt) return ''; + + for (const separator of ['\n\n', '\n', '\r\n\r\n', '\r\n']) { + const prefix = `${prompt}${separator}`; + if (sanitized.startsWith(prefix)) { + return sanitized.slice(prefix.length).trim(); + } + } + + return sanitized; +} + +export function collectGeminiTranscriptAdditions( + beforeLines: string[], + currentLines: string[], + promptText: string, +): string { + const beforeSet = new Set(beforeLines); + const additions = currentLines + .filter((line) => !beforeSet.has(line)) + .map((line) => sanitizeGeminiResponseText(line, promptText)) + .filter((line) => line && line !== promptText); + + return additions.join('\n').trim(); +} + +function getStateScript(): string { + return ` + (() => { + const signInNode = Array.from(document.querySelectorAll('a, button')).find((node) => { + const text = (node.textContent || '').trim().toLowerCase(); + const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase(); + const href = node.getAttribute('href') || ''; + return text === 'sign in' + || aria === 'sign in' + || href.includes('accounts.google.com/ServiceLogin'); + }); + + const composer = document.querySelector('[aria-label="Enter a prompt for Gemini"], [aria-label*="prompt for Gemini"], .ql-editor[aria-label*="Gemini"], [contenteditable="true"][aria-label*="Gemini"]'); + const sendButton = document.querySelector('button[aria-label="Send message"]'); + + return { + url: window.location.href, + title: document.title || '', + isSignedIn: signInNode ? false : (composer ? true : null), + composerLabel: composer?.getAttribute('aria-label') || '', + canSend: !!(sendButton && !sendButton.disabled), + }; + })() + `; +} + +function getTranscriptLinesScript(): string { + return ` + (() => { + const clean = (value) => (value || '') + .replace(/\\u00a0/g, ' ') + .replace(/\\n{3,}/g, '\\n\\n') + .trim(); + + const main = document.querySelector('main') || document.body; + const root = main.cloneNode(true); + + const removableSelectors = [ + 'button', + 'nav', + 'header', + 'footer', + '[aria-label="Enter a prompt for Gemini"]', + '[aria-label*="prompt for Gemini"]', + '.input-area-container', + '.input-wrapper', + '.textbox-container', + '.ql-toolbar', + '.send-button', + '.main-menu-button', + '.sign-in-button', + ]; + + for (const selector of removableSelectors) { + root.querySelectorAll(selector).forEach((node) => node.remove()); + } + root.querySelectorAll('script, style, noscript').forEach((node) => node.remove()); + + const stopLines = new Set([ + 'Gemini', + 'Google Terms', + 'Google Privacy Policy', + 'Meet Gemini, your personal AI assistant', + 'Conversation with Gemini', + 'Ask Gemini 3', + 'Write', + 'Plan', + 'Research', + 'Learn', + 'Fast', + 'send', + 'Microphone', + 'Main menu', + 'New chat', + 'Sign in', + 'Google Terms Opens in a new window', + 'Google Privacy Policy Opens in a new window', + ]); + + const noisyPatterns = [ + /^Google Terms$/, + /^Google Privacy Policy$/, + /^Gemini is AI and can make mistakes\.?$/, + /^and the$/, + /^apply\.$/, + /^Opens in a new window$/, + /^Open mode picker$/, + /^Open upload file menu$/, + /^Tools$/, + ]; + + return clean(root.innerText || root.textContent || '') + .split('\\n') + .map((line) => clean(line)) + .filter((line) => line + && line.length <= 4000 + && !stopLines.has(line) + && !noisyPatterns.some((pattern) => pattern.test(line))); + })() + `; +} + +function getTurnsScript(): string { + return ` + (() => { + const clean = (value) => (value || '') + .replace(/\\u00a0/g, ' ') + .replace(/\\n{3,}/g, '\\n\\n') + .trim(); + + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const selectors = [ + '[data-testid*="message"]', + '[data-test-id*="message"]', + '[class*="message"]', + '[class*="conversation-turn"]', + '[class*="query-text"]', + '[class*="response-text"]', + ]; + + const roots = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))); + const unique = roots.filter((el, index, all) => all.indexOf(el) === index).filter(isVisible); + + const turns = unique.map((el) => { + const text = clean(el.innerText || el.textContent || ''); + if (!text) return null; + + const roleAttr = [ + el.getAttribute('data-message-author-role'), + el.getAttribute('data-role'), + el.getAttribute('aria-label'), + el.getAttribute('class'), + ].filter(Boolean).join(' ').toLowerCase(); + + let role = ''; + if (roleAttr.includes('user') || roleAttr.includes('query')) role = 'User'; + else if (roleAttr.includes('assistant') || roleAttr.includes('model') || roleAttr.includes('response') || roleAttr.includes('gemini')) role = 'Assistant'; + + return role ? { Role: role, Text: text } : null; + }).filter(Boolean); + + const deduped = []; + const seen = new Set(); + for (const turn of turns) { + const key = turn.Role + '::' + turn.Text; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(turn); + } + return deduped; + })() + `; +} + +function fillAndSubmitComposerScript(text: string): string { + return ` + ((inputText) => { + const cleanInsert = (el) => { + if (!(el instanceof HTMLElement)) throw new Error('Composer is not editable'); + el.focus(); + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + el.textContent = ''; + document.execCommand('insertText', false, inputText); + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: inputText, inputType: 'insertText' })); + }; + + const composer = document.querySelector('[aria-label="Enter a prompt for Gemini"], [aria-label*="prompt for Gemini"], .ql-editor[aria-label*="Gemini"], [contenteditable="true"][aria-label*="Gemini"]'); + if (!(composer instanceof HTMLElement)) { + throw new Error('Could not find Gemini composer'); + } + + cleanInsert(composer); + + const sendButton = document.querySelector('button[aria-label="Send message"]'); + if (sendButton instanceof HTMLButtonElement && !sendButton.disabled) { + sendButton.click(); + return 'button'; + } + + composer.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); + composer.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); + return 'enter'; + })(${JSON.stringify(text)}) + `; +} + +function clickNewChatScript(): string { + return ` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const candidates = Array.from(document.querySelectorAll('button, a')).filter((node) => { + const text = (node.textContent || '').trim().toLowerCase(); + const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase(); + return isVisible(node) && (text === 'new chat' || aria === 'new chat'); + }); + + const target = candidates.find((node) => !node.hasAttribute('disabled')) || candidates[0]; + if (target instanceof HTMLElement) { + target.click(); + return 'clicked'; + } + return 'navigate'; + })() + `; +} + +function currentUrlScript(): string { + return 'window.location.href'; +} + +export async function isOnGemini(page: IPage): Promise { + const url = await page.evaluate(currentUrlScript()).catch(() => ''); + if (typeof url !== 'string' || !url) return false; + try { + const hostname = new URL(url).hostname; + return hostname === GEMINI_DOMAIN || hostname.endsWith(`.${GEMINI_DOMAIN}`); + } catch { + return false; + } +} + +export async function ensureGeminiPage(page: IPage): Promise { + if (!(await isOnGemini(page))) { + await page.goto(GEMINI_APP_URL, { waitUntil: 'load', settleMs: 2500 }); + await page.wait(1); + } +} + +export async function getGeminiPageState(page: IPage): Promise { + await ensureGeminiPage(page); + return await page.evaluate(getStateScript()) as GeminiPageState; +} + +export async function startNewGeminiChat(page: IPage): Promise<'clicked' | 'navigate'> { + await ensureGeminiPage(page); + const action = await page.evaluate(clickNewChatScript()) as 'clicked' | 'navigate'; + if (action === 'navigate') { + await page.goto(GEMINI_APP_URL, { waitUntil: 'load', settleMs: 2500 }); + } + await page.wait(1); + return action; +} + +export async function getGeminiVisibleTurns(page: IPage): Promise { + await ensureGeminiPage(page); + const turns = await page.evaluate(getTurnsScript()) as GeminiTurn[]; + if (Array.isArray(turns) && turns.length > 0) return turns; + + const lines = await getGeminiTranscriptLines(page); + return lines.map((line) => ({ Role: 'System', Text: line })); +} + +export async function getGeminiTranscriptLines(page: IPage): Promise { + await ensureGeminiPage(page); + return await page.evaluate(getTranscriptLinesScript()) as string[]; +} + +export async function sendGeminiMessage(page: IPage, text: string): Promise<'button' | 'enter'> { + await ensureGeminiPage(page); + const submittedBy = await page.evaluate(fillAndSubmitComposerScript(text)) as 'button' | 'enter'; + await page.wait(1); + return submittedBy; +} + + + +export async function getGeminiVisibleImageUrls(page: IPage): Promise { + await ensureGeminiPage(page); + return await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 32 && rect.height > 32; + }; + + const imgs = Array.from(document.querySelectorAll('main img')).filter((img) => img instanceof HTMLImageElement && isVisible(img)); + const urls = []; + const seen = new Set(); + + for (const img of imgs) { + const src = img.currentSrc || img.src || ''; + const alt = (img.getAttribute('alt') || '').toLowerCase(); + const width = img.naturalWidth || img.width || 0; + const height = img.naturalHeight || img.height || 0; + if (!src) continue; + if (alt.includes('avatar') || alt.includes('logo') || alt.includes('icon')) continue; + if (width < 128 && height < 128) continue; + if (seen.has(src)) continue; + seen.add(src); + urls.push(src); + } + return urls; + })() + `) as string[]; +} + +export async function waitForGeminiImages( + page: IPage, + beforeUrls: string[], + timeoutSeconds: number, +): Promise { + const beforeSet = new Set(beforeUrls); + const pollIntervalSeconds = 3; + const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds)); + let lastUrls: string[] = []; + let stableCount = 0; + + for (let index = 0; index < maxPolls; index += 1) { + await page.wait(index === 0 ? 2 : pollIntervalSeconds); + const urls = (await getGeminiVisibleImageUrls(page)).filter((url) => !beforeSet.has(url)); + if (urls.length === 0) continue; + + const key = urls.join('\n'); + const prevKey = lastUrls.join('\n'); + if (key == prevKey) stableCount += 1; + else { + lastUrls = urls; + stableCount = 1; + } + + if (stableCount >= 2 || index === maxPolls - 1) return lastUrls; + } + + return lastUrls; +} + +export interface GeminiImageAsset { + url: string; + dataUrl: string; + mimeType: string; + width: number; + height: number; +} + +export async function exportGeminiImages(page: IPage, urls: string[]): Promise { + await ensureGeminiPage(page); + const urlsJson = JSON.stringify(urls); + return await page.evaluate(` + (async (targetUrls) => { + const blobToDataUrl = (blob) => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(new Error('Failed to read blob')); + reader.readAsDataURL(blob); + }); + + const inferMime = (value, fallbackUrl) => { + if (value) return value; + const lower = String(fallbackUrl || '').toLowerCase(); + if (lower.includes('.png')) return 'image/png'; + if (lower.includes('.webp')) return 'image/webp'; + if (lower.includes('.gif')) return 'image/gif'; + return 'image/jpeg'; + }; + + const images = Array.from(document.querySelectorAll('main img')); + const results = []; + + for (const targetUrl of targetUrls) { + const img = images.find((node) => (node.currentSrc || node.src || '') === targetUrl); + let dataUrl = ''; + let mimeType = 'image/jpeg'; + const width = img?.naturalWidth || img?.width || 0; + const height = img?.naturalHeight || img?.height || 0; + + try { + if (String(targetUrl).startsWith('data:')) { + dataUrl = String(targetUrl); + mimeType = (String(targetUrl).match(/^data:([^;]+);/i) || [])[1] || 'image/png'; + } else { + const res = await fetch(String(targetUrl), { credentials: 'include' }); + if (res.ok) { + const blob = await res.blob(); + mimeType = inferMime(blob.type, targetUrl); + dataUrl = await blobToDataUrl(blob); + } + } + } catch {} + + if (!dataUrl && img instanceof HTMLImageElement) { + try { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(img, 0, 0); + dataUrl = canvas.toDataURL('image/png'); + mimeType = 'image/png'; + } + } catch {} + } + + if (dataUrl) { + results.push({ url: String(targetUrl), dataUrl, mimeType, width, height }); + } + } + + return results; + })(${urlsJson}) + `) as GeminiImageAsset[]; +} +export async function waitForGeminiResponse( + page: IPage, + beforeLines: string[], + promptText: string, + timeoutSeconds: number, +): Promise { + const getCandidate = async (): Promise => { + const turns = await getGeminiVisibleTurns(page); + const assistantCandidate = [...turns].reverse().find((turn) => turn.Role === 'Assistant'); + const visibleCandidate = assistantCandidate + ? sanitizeGeminiResponseText(assistantCandidate.Text, promptText) + : ''; + if (visibleCandidate && visibleCandidate !== promptText) return visibleCandidate; + + const lines = await getGeminiTranscriptLines(page); + return collectGeminiTranscriptAdditions(beforeLines, lines, promptText); + }; + + const pollIntervalSeconds = 2; + const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds)); + let lastCandidate = ''; + let stableCount = 0; + + for (let index = 0; index < maxPolls; index += 1) { + await page.wait(index === 0 ? 1.5 : pollIntervalSeconds); + const candidate = await getCandidate(); + if (!candidate) continue; + + if (candidate === lastCandidate) stableCount += 1; + else { + lastCandidate = candidate; + stableCount = 1; + } + + if (stableCount >= 2 || index === maxPolls - 1) return candidate; + } + + return lastCandidate; +} diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index c0d86de0..7a1f7ef7 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -153,3 +153,50 @@ describe('commanderAdapter command aliases', () => { expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false); }); }); + +describe('commanderAdapter default formats', () => { + const cmd: CliCommand = { + site: 'gemini', + name: 'ask', + description: 'Ask Gemini', + browser: false, + args: [], + columns: ['response'], + defaultFormat: 'plain', + func: vi.fn(), + }; + + beforeEach(() => { + mockExecuteCommand.mockReset(); + mockExecuteCommand.mockResolvedValue([{ response: 'hello' }]); + mockRenderOutput.mockReset(); + delete process.env.OPENCLI_VERBOSE; + process.exitCode = undefined; + }); + + it('uses the command defaultFormat when the user keeps the default table format', async () => { + const program = new Command(); + const siteCmd = program.command('gemini'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync(['node', 'opencli', 'gemini', 'ask']); + + expect(mockRenderOutput).toHaveBeenCalledWith( + [{ response: 'hello' }], + expect.objectContaining({ fmt: 'plain' }), + ); + }); + + it('respects an explicit user format over the command defaultFormat', async () => { + const program = new Command(); + const siteCmd = program.command('gemini'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync(['node', 'opencli', 'gemini', 'ask', '--format', 'json']); + + expect(mockRenderOutput).toHaveBeenCalledWith( + [{ response: 'hello' }], + expect.objectContaining({ fmt: 'json' }), + ); + }); +}); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 8b466625..7490812f 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -69,7 +69,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi } } subCmd - .option('-f, --format ', 'Output format: table, json, yaml, md, csv', 'table') + .option('-f, --format ', 'Output format: table, plain, json, yaml, md, csv', 'table') .option('-v, --verbose', 'Debug output', false); subCmd.addHelpText('after', formatRegistryHelpText(cmd)); @@ -95,7 +95,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi } const verbose = optionsRecord.verbose === true; - const format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table'; + let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table'; if (verbose) process.env.OPENCLI_VERBOSE = '1'; if (cmd.deprecated) { const message = typeof cmd.deprecated === 'string' ? cmd.deprecated : `${fullName(cmd)} is deprecated.`; @@ -108,10 +108,14 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi return; } + const resolved = getRegistry().get(fullName(cmd)) ?? cmd; + if (format === 'table' && resolved.defaultFormat) { + format = resolved.defaultFormat; + } + if (verbose && (!result || (Array.isArray(result) && result.length === 0))) { console.error(chalk.yellow('[Verbose] Warning: Command returned an empty result.')); } - const resolved = getRegistry().get(fullName(cmd)) ?? cmd; renderOutput(result, { fmt: format, columns: resolved.columns, diff --git a/src/output.test.ts b/src/output.test.ts index b35cbf2e..c339e055 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -89,4 +89,21 @@ describe('render', () => { const calls = log.mock.calls.map(c => c[0]); expect(calls[1]).toBe('test,'); }); + + it('renders single-field rows in plain mode as the bare value', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + render([{ response: 'Gemini says hi' }], { fmt: 'plain' }); + expect(log).toHaveBeenCalledWith('Gemini says hi'); + }); + + it('renders multi-field rows in plain mode as key-value lines', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + render([{ status: 'ok', file: '~/tmp/a.png', link: 'https://example.com' }], { fmt: 'plain' }); + const calls = log.mock.calls.map(c => c[0]); + expect(calls).toEqual([ + 'status: ok', + 'file: ~/tmp/a.png', + 'link: https://example.com', + ]); + }); }); diff --git a/src/output.ts b/src/output.ts index 330b07b4..ebe8ef15 100644 --- a/src/output.ts +++ b/src/output.ts @@ -33,6 +33,7 @@ export function render(data: unknown, opts: RenderOptions = {}): void { } switch (fmt) { case 'json': renderJson(data); break; + case 'plain': renderPlain(data, opts); break; case 'md': case 'markdown': renderMarkdown(data, opts); break; case 'csv': renderCsv(data, opts); break; case 'yaml': case 'yml': renderYaml(data); break; @@ -74,6 +75,32 @@ function renderTable(data: unknown, opts: RenderOptions): void { function renderJson(data: unknown): void { console.log(JSON.stringify(data, null, 2)); } +function renderPlain(data: unknown, opts: RenderOptions): void { + const rows = normalizeRows(data); + if (!rows.length) return; + + // Single-row single-field shortcuts for chat-style commands. + if (rows.length === 1) { + const row = rows[0]; + const entries = Object.entries(row); + if (entries.length === 1) { + const [key, value] = entries[0]; + if (key === 'response' || key === 'content' || key === 'text' || key === 'value') { + console.log(String(value ?? '')); + return; + } + } + } + + rows.forEach((row, index) => { + const entries = Object.entries(row).filter(([, value]) => value !== undefined && value !== null && String(value) !== ''); + entries.forEach(([key, value]) => { + console.log(`${key}: ${value}`); + }); + if (index < rows.length - 1) console.log(''); + }); +} + function renderMarkdown(data: unknown, opts: RenderOptions): void { const rows = normalizeRows(data); diff --git a/src/registry.test.ts b/src/registry.test.ts index 32e270e5..2970247e 100644 --- a/src/registry.test.ts +++ b/src/registry.test.ts @@ -75,6 +75,18 @@ describe('cli() registration', () => { expect(registry.get('test-registry/compat')).toBe(cmd); expect(registry.get('test-registry/legacy-name')).toBe(cmd); }); + + it('preserves defaultFormat on the registered command', () => { + const cmd = cli({ + site: 'test-registry', + name: 'plain-default', + description: 'prefers plain output', + defaultFormat: 'plain', + }); + + expect(cmd.defaultFormat).toBe('plain'); + expect(getRegistry().get('test-registry/plain-default')?.defaultFormat).toBe('plain'); + }); }); describe('fullName', () => { diff --git a/src/registry.ts b/src/registry.ts index 32bbd040..eb9a12e7 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -62,6 +62,8 @@ export interface CliCommand { * - `string`: navigate to this specific URL instead of the domain root */ navigateBefore?: boolean | string; + /** Override the default CLI output format when the user does not pass -f/--format. */ + defaultFormat?: 'table' | 'plain' | 'json' | 'yaml' | 'yml' | 'md' | 'markdown' | 'csv'; } /** Internal extension for lazy-loaded TS modules (not exposed in public API) */ @@ -105,6 +107,7 @@ export function cli(opts: CliOptions): CliCommand { deprecated: opts.deprecated, replacedBy: opts.replacedBy, navigateBefore: opts.navigateBefore, + defaultFormat: opts.defaultFormat, }; registerCommand(cmd);