9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import {
|
|
GEMINI_DOMAIN,
|
|
ensureGeminiPage,
|
|
getCurrentGeminiModel,
|
|
readGeminiSnapshot,
|
|
selectGeminiModel,
|
|
selectGeminiThinking,
|
|
sendGeminiMessage,
|
|
startNewGeminiChat,
|
|
waitForGeminiResponse,
|
|
waitForGeminiSubmission,
|
|
} from './utils.js';
|
|
import { pickModelPickerScript, readMenuModelsScript } from './models.js';
|
|
function normalizeBooleanFlag(value) {
|
|
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]';
|
|
|
|
/**
|
|
* Validate a --model value for gemini ask.
|
|
* Throws ArgumentError for short aliases or invalid formats.
|
|
*/
|
|
function unwrapBrowserBridgeEnvelope(value) {
|
|
if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value) {
|
|
if ('data' in value) return value.data;
|
|
throw new CommandExecutionError('Gemini model discovery returned a malformed Browser Bridge envelope');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireDiscoveredModels(value) {
|
|
const unwrapped = unwrapBrowserBridgeEnvelope(value);
|
|
if (!Array.isArray(unwrapped)) {
|
|
throw new CommandExecutionError('Gemini model discovery returned a malformed result');
|
|
}
|
|
for (const row of unwrapped) {
|
|
if (!row || typeof row.model !== 'string' || !Array.isArray(row.thinkingValues)) {
|
|
throw new CommandExecutionError('Gemini model discovery returned a malformed row');
|
|
}
|
|
}
|
|
return unwrapped;
|
|
}
|
|
|
|
function validateAskModelValue(value) {
|
|
if (!value) {
|
|
throw new ArgumentError(
|
|
'--model requires a canonical model id (e.g. "2.5-flash"). ' +
|
|
'Use "opencli gemini models" to list available values.'
|
|
);
|
|
}
|
|
// Reject short aliases like "pro", "flash", "flash-lite" that lack a version number.
|
|
if (!/\d+\.\d+/.test(value)) {
|
|
throw new ArgumentError(
|
|
'--model "' + value + '" is not accepted. ' +
|
|
'Short aliases like "pro", "flash", or "flash-lite" are not supported. ' +
|
|
'Use a canonical model id (e.g. "2.5-flash"). ' +
|
|
'Use "opencli gemini models" to list available values.'
|
|
);
|
|
}
|
|
// Must match canonical format: X.Y-variant
|
|
if (!/^\d+\.\d+-[a-z][a-z-]*$/.test(value)) {
|
|
throw new ArgumentError(
|
|
'--model "' + value + '" is not a valid canonical model id. ' +
|
|
'Expected format: version-variant (e.g. "2.5-flash", "3.1-pro"). ' +
|
|
'Use "opencli gemini models" to list available values.'
|
|
);
|
|
}
|
|
}
|
|
|
|
export const __test__ = {
|
|
validateAskModelValue,
|
|
};
|
|
|
|
export const askCommand = cli({
|
|
site: 'gemini',
|
|
name: 'ask',
|
|
access: 'write',
|
|
description: 'Send a prompt to Gemini and return only the assistant response',
|
|
domain: GEMINI_DOMAIN,
|
|
strategy: Strategy.COOKIE,
|
|
browser: true,
|
|
siteSession: 'persistent',
|
|
navigateBefore: false,
|
|
defaultFormat: 'plain',
|
|
args: [
|
|
{ name: 'prompt', required: true, positional: true, help: 'Prompt to send' },
|
|
{ name: 'model', type: 'string', required: false, help: 'Gemini model to use (e.g. "2.5-flash"). Use "opencli gemini models" to list available values.' },
|
|
{ name: 'timeout', type: 'int', 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' },
|
|
{ name: 'thinking', required: false, help: 'Thinking level: standard or extended (omitted = leave unchanged)', default: null },
|
|
],
|
|
columns: ['response'],
|
|
func: async (page, kwargs) => {
|
|
const prompt = kwargs.prompt;
|
|
const timeout = kwargs.timeout;
|
|
if (!Number.isInteger(timeout) || timeout < 1) {
|
|
throw new ArgumentError('--timeout must be a positive integer (seconds)');
|
|
}
|
|
|
|
// ── New chat (must happen before model/thinking selection) ──────
|
|
const startFresh = normalizeBooleanFlag(kwargs.new);
|
|
if (startFresh)
|
|
await startNewGeminiChat(page);
|
|
|
|
// ── Early validation: model format & thinking value ────────────
|
|
const hasModel = kwargs.model !== undefined && kwargs.model !== null;
|
|
const hasThinking = kwargs.thinking != null;
|
|
|
|
let modelValue = null;
|
|
if (hasModel) {
|
|
modelValue = String(kwargs.model).trim();
|
|
validateAskModelValue(modelValue);
|
|
}
|
|
|
|
if (hasThinking) {
|
|
const thinkingRaw = String(kwargs.thinking ?? '').trim();
|
|
const thinkingValue = thinkingRaw.toLowerCase();
|
|
if (thinkingValue !== 'standard' && thinkingValue !== 'extended') {
|
|
throw new ArgumentError(
|
|
`--thinking must be 'standard' or 'extended', got '${thinkingRaw}'`,
|
|
'Run `opencli gemini models` to see available thinking levels.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Model and thinking discovery (shared by both model and
|
|
// thinking selection) ──────────────────────────────────────
|
|
let discoveredModels = null;
|
|
if (hasModel || hasThinking) {
|
|
await ensureGeminiPage(page);
|
|
|
|
// Open the picker menu (click the model-picker button).
|
|
const pickerRaw = await page.evaluate(`
|
|
(() => {
|
|
${pickModelPickerScript()}
|
|
const picker = findModelPicker();
|
|
if (!picker) return { ok: false, reason: 'Gemini model picker button was not found' };
|
|
try { picker.click(); } catch (_) { return { ok: false, reason: 'Failed to click Gemini model picker button' }; }
|
|
return { ok: true };
|
|
})()
|
|
`);
|
|
const pickerResult = unwrapBrowserBridgeEnvelope(pickerRaw);
|
|
if (!pickerResult || typeof pickerResult !== 'object' || !pickerResult.ok) {
|
|
throw new CommandExecutionError(
|
|
pickerResult?.reason || 'Failed to open Gemini model picker for model discovery'
|
|
);
|
|
}
|
|
|
|
// Wait for React to render the menu.
|
|
await page.wait(1.0);
|
|
|
|
// Read model entries from the open menu.
|
|
const raw = await page.evaluate(readMenuModelsScript());
|
|
discoveredModels = requireDiscoveredModels(raw);
|
|
|
|
// Close the menu. Thinking support is intentionally not copied from
|
|
// the currently-visible UI into every model row: Gemini exposes
|
|
// thinking controls for the current selection, not a reliable
|
|
// per-model capability matrix.
|
|
await page.evaluate(`(() => { try { document.body.click(); } catch (_) {} })()`);
|
|
}
|
|
|
|
// ── Model selection ──────────────────────────────────────────────
|
|
if (hasModel) {
|
|
const availableModels = discoveredModels || [];
|
|
if (!Array.isArray(availableModels) || availableModels.length === 0) {
|
|
throw new CommandExecutionError(
|
|
'Gemini model discovery returned no selectable models. Gemini Web may have changed its model selector UI.'
|
|
);
|
|
}
|
|
const availableIds = availableModels.map((m) => m?.model).filter(Boolean);
|
|
if (!availableIds.includes(modelValue)) {
|
|
throw new ArgumentError(
|
|
'Unknown model "' + modelValue + '". ' +
|
|
'Available models: ' + availableIds.join(', ') + '. ' +
|
|
'Use "opencli gemini models" to see available values.'
|
|
);
|
|
}
|
|
await selectGeminiModel(page, modelValue);
|
|
}
|
|
|
|
// ── Thinking validation and selection ──────────────────────────
|
|
if (hasThinking) {
|
|
// Reuse the pre-discovered models from the shared discovery call.
|
|
const discovered = discoveredModels || [];
|
|
|
|
// When --model was supplied, scope thinking to the selected model;
|
|
// otherwise scope to the current model from the web UI.
|
|
let targetModelId;
|
|
if (hasModel) {
|
|
targetModelId = modelValue;
|
|
} else {
|
|
targetModelId = await getCurrentGeminiModel(page);
|
|
}
|
|
|
|
// Build a map of model → thinkingValues for lookup.
|
|
const modelThinkingMap = new Map();
|
|
const allThinking = new Set();
|
|
for (const entry of discovered) {
|
|
if (entry && typeof entry.model === 'string' && Array.isArray(entry.thinkingValues)) {
|
|
const tvs = entry.thinkingValues.filter((tv) => typeof tv === 'string');
|
|
if (tvs.length > 0) {
|
|
modelThinkingMap.set(entry.model, tvs);
|
|
for (const tv of tvs) allThinking.add(tv);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolve thinkingValue from early validation.
|
|
const thinkingValue = String(kwargs.thinking ?? '').trim().toLowerCase();
|
|
|
|
// Validate: if we can identify the target model, scope to its
|
|
// thinking values; otherwise fall back to the union across all models.
|
|
const targetModelThinking =
|
|
targetModelId && modelThinkingMap.has(targetModelId)
|
|
? modelThinkingMap.get(targetModelId)
|
|
: null;
|
|
|
|
if (targetModelThinking) {
|
|
// Scoped to the target model.
|
|
if (!targetModelThinking.includes(thinkingValue)) {
|
|
const availableForModel = targetModelThinking.sort().join(', ');
|
|
throw new ArgumentError(
|
|
`--thinking '${thinkingValue}' is not available for the ${hasModel ? 'selected' : 'current'} model ('${targetModelId}')`,
|
|
`Model '${targetModelId}' supports: ${availableForModel}. Run \`opencli gemini models\` for all models.`,
|
|
);
|
|
}
|
|
} else if (allThinking.size > 0 && !allThinking.has(thinkingValue)) {
|
|
// Union fallback when the target model cannot be identified.
|
|
const availableList = [...allThinking].sort().join(', ');
|
|
throw new ArgumentError(
|
|
`--thinking '${thinkingValue}' is not currently available`,
|
|
`Available thinking values: ${availableList}. Run \`opencli gemini models\` for details.`,
|
|
);
|
|
}
|
|
|
|
// Select the requested thinking level before snapshot.
|
|
const selected = await selectGeminiThinking(page, thinkingValue);
|
|
if (!selected) {
|
|
// Build an informative hint from what we know.
|
|
const hintParts = [];
|
|
if (targetModelThinking && targetModelThinking.length > 0) {
|
|
hintParts.push(`Model '${targetModelId}' supports: ${targetModelThinking.sort().join(', ')}.`);
|
|
} else if (allThinking.size > 0) {
|
|
hintParts.push(`Available thinking values: ${[...allThinking].sort().join(', ')}.`);
|
|
}
|
|
hintParts.push('Run `opencli gemini models` for details.');
|
|
throw new ArgumentError(
|
|
`Could not select thinking level '${thinkingValue}' in the Gemini web UI`,
|
|
hintParts.join(' '),
|
|
);
|
|
}
|
|
}
|
|
|
|
const before = await readGeminiSnapshot(page);
|
|
await sendGeminiMessage(page, prompt);
|
|
const submissionStartedAt = Date.now();
|
|
const submitted = await waitForGeminiSubmission(page, before, timeout);
|
|
if (!submitted) {
|
|
return [{ response: `💬 ${NO_RESPONSE_PREFIX} No Gemini response within ${timeout}s.` }];
|
|
}
|
|
const remainingTimeoutSeconds = Math.max(0, timeout - Math.ceil((Date.now() - submissionStartedAt) / 1000));
|
|
const response = await waitForGeminiResponse(page, submitted, prompt, remainingTimeoutSeconds);
|
|
if (!response) {
|
|
return [{ response: `💬 ${NO_RESPONSE_PREFIX} No Gemini response within ${timeout}s.` }];
|
|
}
|
|
return [{ response: `💬 ${response}` }];
|
|
},
|
|
});
|