chore: import upstream snapshot with attribution
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, selectorError } from '@jackwener/opencli/errors';
|
||||
|
||||
// Codex Desktop App exposes the active model + reasoning level on a button
|
||||
// in the composer bottom toolbar. As of 2026-05-31 the button has no
|
||||
// stable aria-label or data-testid — we anchor to it by walking up from
|
||||
// the composer contenteditable and finding the button whose visible text
|
||||
// matches a known pattern (model version like "5.5"/"5.4" OR reasoning
|
||||
// level "Low"/"Medium"/"High"/"Extra High"/"Auto"/"Speed").
|
||||
//
|
||||
// Clicking the button opens a menu with BOTH:
|
||||
// - Reasoning levels: Low / Medium / High / Extra High / Speed / Auto
|
||||
// - Model versions: GPT-5.5 / GPT-5.4 / ...
|
||||
// Either kind of value can be selected via 'opencli codex model <name>'.
|
||||
|
||||
const MODEL_BTN_TEXT_RE = /5\.\d|[Ee]xtra [Hh]igh|^High$|^Medium$|^Low$|^Auto$|^Fast$|^Speed$|^Pro$|GPT-/;
|
||||
const MODEL_BTN_PATTERN = MODEL_BTN_TEXT_RE.source;
|
||||
|
||||
function unwrapEvaluateResult(result) {
|
||||
if (result && typeof result === 'object' && 'data' in result && 'session' in result) {
|
||||
return result.data;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeModelText(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\bgpt[-\s]*/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const REASONING_OPTIONS = ['extra high', 'medium', 'high', 'low', 'auto', 'fast', 'speed', 'pro'];
|
||||
|
||||
function extractReasoning(value) {
|
||||
return REASONING_OPTIONS.find(option => value === option || value.endsWith(` ${option}`)) || '';
|
||||
}
|
||||
|
||||
function extractModelVersion(value) {
|
||||
const match = value.match(/(?:^|\s)(\d+(?:\.\d+)?)(?=\s|$)/);
|
||||
return match?.[1] || '';
|
||||
}
|
||||
|
||||
export function findUniqueModelOption(labels, rawName) {
|
||||
const name = normalizeModelText(rawName);
|
||||
if (!name) {
|
||||
throw new ArgumentError('model name cannot be empty');
|
||||
}
|
||||
const normalized = labels.map((label) => ({ label, normalized: normalizeModelText(label) }));
|
||||
const exact = normalized.filter(item => item.normalized === name);
|
||||
if (exact.length === 1) {
|
||||
return exact[0].label;
|
||||
}
|
||||
if (exact.length > 1) {
|
||||
throw new CommandExecutionError(`Model name "${rawName}" is ambiguous.`, `Matches: ${exact.map(item => item.label).join(', ')}`);
|
||||
}
|
||||
const partial = normalized.filter(item => item.normalized.includes(name));
|
||||
if (partial.length === 1) {
|
||||
return partial[0].label;
|
||||
}
|
||||
if (partial.length > 1) {
|
||||
throw new CommandExecutionError(`Model name "${rawName}" is ambiguous.`, `Matches: ${partial.map(item => item.label).join(', ')}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function modelSelectionVerified(current, chosen) {
|
||||
const active = normalizeModelText(current);
|
||||
const selected = normalizeModelText(chosen);
|
||||
if (!active || !selected) {
|
||||
return false;
|
||||
}
|
||||
if (active === selected) {
|
||||
return true;
|
||||
}
|
||||
if (REASONING_OPTIONS.includes(selected)) {
|
||||
return extractReasoning(active) === selected;
|
||||
}
|
||||
const selectedModel = extractModelVersion(selected);
|
||||
if (selectedModel) {
|
||||
return extractModelVersion(active) === selectedModel;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const modelCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'model',
|
||||
access: 'write',
|
||||
description: 'Read, list, or switch the active model / reasoning level in Codex Desktop. The composer toolbar button toggles a menu that mixes model variants (GPT-5.5, Speed) with reasoning levels (Low/Medium/High/Extra High).',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'name', required: false, positional: true, help: 'Substring (case-insensitive) of a model / reasoning level to switch to. Omit to read current.' },
|
||||
{ name: 'list', type: 'boolean', default: false, help: 'List all menu options (does not switch)' },
|
||||
],
|
||||
columns: ['Status', 'Model'],
|
||||
func: async (page, kwargs) => {
|
||||
const name = String(kwargs.name || '').trim().toLowerCase();
|
||||
const listOnly = kwargs.list === true || kwargs.list === 'true';
|
||||
const patternJson = JSON.stringify(MODEL_BTN_PATTERN);
|
||||
|
||||
const current = unwrapEvaluateResult(await page.evaluate(`(function() {
|
||||
const re = new RegExp(${patternJson});
|
||||
const composers = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter((el) => el.offsetParent);
|
||||
const last = composers[composers.length - 1];
|
||||
if (!last) return '';
|
||||
let root = last;
|
||||
for (let i = 0; i < 5; i++) root = root.parentElement || root;
|
||||
const btns = Array.from(root.querySelectorAll('button')).filter((b) => b.offsetParent);
|
||||
const match = btns.find((b) => re.test((b.textContent || '').trim()));
|
||||
return match ? (match.textContent || '').trim() : '';
|
||||
})()`));
|
||||
if (!current) {
|
||||
throw selectorError('Codex model button (composer toolbar). Make sure a chat is open.');
|
||||
}
|
||||
|
||||
if (!name && !listOnly) {
|
||||
return [{ Status: 'Active', Model: current }];
|
||||
}
|
||||
|
||||
const namejson = JSON.stringify(listOnly ? '' : name);
|
||||
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const re = new RegExp(${patternJson});
|
||||
const composers = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter((el) => el.offsetParent);
|
||||
const last = composers[composers.length - 1];
|
||||
if (!last) return { ok: false, reason: 'composer not found' };
|
||||
let root = last;
|
||||
for (let i = 0; i < 5; i++) root = root.parentElement || root;
|
||||
const btns = Array.from(root.querySelectorAll('button')).filter((b) => b.offsetParent);
|
||||
const trigger = btns.find((b) => re.test((b.textContent || '').trim()));
|
||||
if (!trigger) return { ok: false, reason: 'model trigger button not found in composer' };
|
||||
|
||||
const r = trigger.getBoundingClientRect();
|
||||
const init = {
|
||||
bubbles: true, cancelable: true, button: 0, buttons: 1,
|
||||
clientX: Math.round(r.left + r.width / 2),
|
||||
clientY: Math.round(r.top + r.height / 2),
|
||||
};
|
||||
trigger.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
|
||||
trigger.dispatchEvent(new MouseEvent('mousedown', init));
|
||||
trigger.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
|
||||
trigger.dispatchEvent(new MouseEvent('mouseup', init));
|
||||
trigger.dispatchEvent(new MouseEvent('click', init));
|
||||
|
||||
let items = [];
|
||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||
await wait(80);
|
||||
items = Array.from(document.querySelectorAll('[role="menuitem"], [role="option"]'))
|
||||
.filter((it) => it instanceof HTMLElement && it.offsetParent);
|
||||
if (items.length) break;
|
||||
}
|
||||
if (!items.length) {
|
||||
return { ok: false, reason: 'Model menu did not open after click.' };
|
||||
}
|
||||
|
||||
function leadingText(el) {
|
||||
const clone = el.cloneNode(true);
|
||||
clone.querySelectorAll('kbd').forEach((k) => k.remove());
|
||||
return (clone.textContent || '').trim();
|
||||
}
|
||||
// Filter unrelated Chat-actions menu items so they don't pollute
|
||||
// the model list — Codex sometimes shares the menu root with
|
||||
// 'Pin chat / Rename chat / Archive chat / Open side chat / Copy /
|
||||
// Fork / Add automation… / Open in new window'.
|
||||
const CHAT_ACTION_LABELS = new Set([
|
||||
'Pin chat', 'Unpin chat', 'Rename chat', 'Archive chat',
|
||||
'Open side chat', 'Copy', 'Fork', 'Add automation…', 'Open in new window',
|
||||
]);
|
||||
const modelItems = items.filter((it) => {
|
||||
const t = leadingText(it).replace(/[⌥⌘⌃⇧⏎].*$/, '').trim();
|
||||
return t && !CHAT_ACTION_LABELS.has(t);
|
||||
});
|
||||
|
||||
const labels = modelItems.map(leadingText);
|
||||
const target = ${namejson};
|
||||
if (!target) {
|
||||
document.body.click();
|
||||
return { ok: true, labels };
|
||||
}
|
||||
const wanted = target.replace(/\\bgpt[-\\s]*/g, '').replace(/\\s+/g, ' ').trim();
|
||||
const normalizedLabels = labels.map((l) => l.toLowerCase().replace(/\\bgpt[-\\s]*/g, '').replace(/\\s+/g, ' ').trim());
|
||||
let matches = normalizedLabels
|
||||
.map((label, index) => ({ label, index }))
|
||||
.filter((item) => item.label === wanted);
|
||||
if (matches.length === 0) {
|
||||
matches = normalizedLabels
|
||||
.map((label, index) => ({ label, index }))
|
||||
.filter((item) => item.label.includes(wanted));
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
document.body.click();
|
||||
return { ok: false, reason: 'No model matched.', detail: 'wanted=' + target + ' available=' + JSON.stringify(labels) };
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
document.body.click();
|
||||
return { ok: false, reason: 'Model name is ambiguous.', detail: 'wanted=' + target + ' matches=' + JSON.stringify(matches.map((m) => labels[m.index])) };
|
||||
}
|
||||
const idx = matches[0].index;
|
||||
const chosen = modelItems[idx];
|
||||
const chosenLabel = labels[idx];
|
||||
|
||||
const cr = chosen.getBoundingClientRect();
|
||||
const cinit = {
|
||||
bubbles: true, cancelable: true, button: 0, buttons: 1,
|
||||
clientX: Math.round(cr.left + cr.width / 2),
|
||||
clientY: Math.round(cr.top + cr.height / 2),
|
||||
};
|
||||
Promise.resolve().then(() => {
|
||||
try {
|
||||
chosen.dispatchEvent(new PointerEvent('pointerdown', { ...cinit, pointerType: 'mouse' }));
|
||||
chosen.dispatchEvent(new MouseEvent('mousedown', cinit));
|
||||
chosen.dispatchEvent(new PointerEvent('pointerup', { ...cinit, pointerType: 'mouse' }));
|
||||
chosen.dispatchEvent(new MouseEvent('mouseup', cinit));
|
||||
chosen.dispatchEvent(new MouseEvent('click', cinit));
|
||||
} catch {}
|
||||
});
|
||||
return { ok: true, switched: true, chosen: chosenLabel, labels };
|
||||
})()`));
|
||||
|
||||
if (!result.ok) {
|
||||
throw new CommandExecutionError(result.reason, result.detail || '');
|
||||
}
|
||||
if (listOnly) {
|
||||
return result.labels.map((m) => ({ Status: m === current ? 'Active' : 'Available', Model: m }));
|
||||
}
|
||||
const selected = findUniqueModelOption(result.labels || [], name);
|
||||
if (!selected) {
|
||||
throw new CommandExecutionError('No model matched.', `wanted=${name} available=${JSON.stringify(result.labels || [])}`);
|
||||
}
|
||||
if (selected !== result.chosen) {
|
||||
throw new CommandExecutionError('Codex model selection was inconsistent.', `expected=${selected} chosen=${result.chosen}`);
|
||||
}
|
||||
let verified = '';
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await page.wait(0.25);
|
||||
const reread = unwrapEvaluateResult(await page.evaluate(`(function() {
|
||||
const re = new RegExp(${patternJson});
|
||||
const composers = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter((el) => el.offsetParent);
|
||||
const last = composers[composers.length - 1];
|
||||
if (!last) return '';
|
||||
let root = last;
|
||||
for (let i = 0; i < 5; i++) root = root.parentElement || root;
|
||||
const btns = Array.from(root.querySelectorAll('button')).filter((b) => b.offsetParent);
|
||||
const match = btns.find((b) => re.test((b.textContent || '').trim()));
|
||||
return match ? (match.textContent || '').trim() : '';
|
||||
})()`));
|
||||
if (modelSelectionVerified(reread, selected)) {
|
||||
verified = reread;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!verified) {
|
||||
throw new CommandExecutionError(
|
||||
`Codex model switch to "${selected}" was not verified.`,
|
||||
'The model menu click may have failed or the model selector text may have drifted.',
|
||||
);
|
||||
}
|
||||
return [{ Status: 'switched', Model: verified }];
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user