mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
4fe9a73ebc
* refactor: migrate adapter imports to package exports Replace all relative imports (../../src/registry.js, ../../browser/cdp.js, etc.) with package exports (@jackwener/opencli/registry, @jackwener/opencli/errors, etc.) across all 484 adapter files. This decouples adapter import resolution from directory structure: - User CLIs in ~/.opencli/clis/ resolve via node_modules symlink - Internal adapters resolve via Node.js self-referencing - No more shim files needed for import resolution Changes: - package.json: add sub-path exports for all public modules - clis/**: replace relative imports with @jackwener/opencli/... - discovery.ts: simplify ensureUserCliCompatShims to symlink-only - registry-api.ts: export CommandArgs type - Remove root-level shim directories (browser/, download/, pipeline/) - Remove shim entries from tsconfig.json include and package.json files * test: add regression tests for package exports Prevents regressions like #788/#791 by: 1. Scanning all adapter files for forbidden relative imports (../../src/, ../../browser/, etc.) — fails if any remain 2. Verifying every package.json export maps to an existing source file 18 new test cases. * fix: use junction on Windows + broaden test patterns - discovery.ts: use 'junction' symlink type on Windows (no admin required) - package-exports.test.ts: generalize forbidden patterns to catch any depth of ../ traversal (not just ../../ and ../../../) * fix: update stale vi.mock/importActual paths in adapter tests Test files still used old relative paths for vi.mock() and vi.importActual() calls. Updated 5 test files to use package exports. Also broadened regression test patterns to catch mock/importActual paths. * fix: use rm instead of unlink for symlink cleanup, add warn on failure Addresses review feedback from Astro-Han: - rm() handles both symlinks and stale directories (unlink fails on dirs) - Log a warning when symlink creation fails instead of silent catch * docs: update import examples to use package exports Update all documentation, contributing guides, and skills to use @jackwener/opencli/registry instead of ../../src/registry.js. Without this, users following the docs would write adapters with broken imports since the old shim files are no longer created.
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import type { IPage } from '@jackwener/opencli/types';
|
|
|
|
const GROK_URL = 'https://grok.com/';
|
|
const RESPONSE_SELECTOR = 'div.message-bubble, [data-testid="message-bubble"]';
|
|
const BLOCKED_PREFIX = '[BLOCKED]';
|
|
const NO_RESPONSE_PREFIX = '[NO RESPONSE]';
|
|
const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing grok.com browser session.';
|
|
|
|
type GrokSendResult = {
|
|
ok?: boolean;
|
|
msg?: string;
|
|
reason?: string;
|
|
detail?: string;
|
|
};
|
|
|
|
function blocked(message: string) {
|
|
return [{ response: `${BLOCKED_PREFIX} ${message} ${SESSION_HINT}` }];
|
|
}
|
|
|
|
function normalizeBubbleText(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
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 pickLatestAssistantCandidate(
|
|
bubbles: unknown[],
|
|
baselineCount: number,
|
|
prompt: string,
|
|
): string {
|
|
const normalizedPrompt = prompt.trim();
|
|
const freshBubbles = bubbles
|
|
.slice(Math.max(0, baselineCount))
|
|
.map(normalizeBubbleText)
|
|
.filter(Boolean);
|
|
|
|
for (let i = freshBubbles.length - 1; i >= 0; i -= 1) {
|
|
if (freshBubbles[i] !== normalizedPrompt) return freshBubbles[i];
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function updateStableState(previousText: string, stableCount: number, nextText: string) {
|
|
if (!nextText) return { previousText: '', stableCount: 0 };
|
|
if (nextText === previousText) return { previousText, stableCount: stableCount + 1 };
|
|
return { previousText: nextText, stableCount: 0 };
|
|
}
|
|
|
|
/** Check whether the tab is already on grok.com (any path). */
|
|
async function isOnGrok(page: IPage): Promise<boolean> {
|
|
// catch handles blank tabs (about:blank) or detached pages
|
|
const url = await page.evaluate('window.location.href').catch(() => '');
|
|
if (typeof url !== 'string' || !url) return false;
|
|
try {
|
|
const hostname = new URL(url).hostname;
|
|
return hostname === 'grok.com' || hostname.endsWith('.grok.com');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function runDefaultAsk(
|
|
page: IPage,
|
|
prompt: string,
|
|
timeoutMs: number,
|
|
newChat: boolean,
|
|
) {
|
|
if (newChat) {
|
|
// Explicitly start a fresh conversation via the homepage
|
|
await page.goto(GROK_URL);
|
|
await page.wait(2);
|
|
await tryStartFreshChat(page);
|
|
await page.wait(2);
|
|
} else if (!(await isOnGrok(page))) {
|
|
// First invocation or tab was recycled — navigate to Grok
|
|
await page.goto(GROK_URL);
|
|
await page.wait(3);
|
|
}
|
|
|
|
const promptJson = JSON.stringify(prompt);
|
|
const sendResult = await page.evaluate(`(async () => {
|
|
try {
|
|
const box = document.querySelector('textarea');
|
|
if (!box) return { ok: false, msg: 'no textarea' };
|
|
box.focus(); box.value = '';
|
|
document.execCommand('selectAll');
|
|
document.execCommand('insertText', false, ${promptJson});
|
|
await new Promise(r => setTimeout(r, 1500));
|
|
const btn = document.querySelector('button[aria-label="\\u63d0\\u4ea4"]');
|
|
if (btn && !btn.disabled) { btn.click(); return { ok: true, msg: 'clicked' }; }
|
|
const sub = [...document.querySelectorAll('button[type="submit"]')].find(b => !b.disabled);
|
|
if (sub) { sub.click(); return { ok: true, msg: 'clicked-submit' }; }
|
|
box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
|
return { ok: true, msg: 'enter' };
|
|
} catch (e) { return { ok: false, msg: e.toString() }; }
|
|
})()`) as GrokSendResult;
|
|
|
|
if (!sendResult || !sendResult.ok) {
|
|
return [{ response: '[SEND FAILED] ' + JSON.stringify(sendResult) }];
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
let lastText = '';
|
|
let stableCount = 0;
|
|
|
|
while (Date.now() - startTime < timeoutMs) {
|
|
await page.wait(3);
|
|
const response = await page.evaluate(`(() => {
|
|
const bubbles = document.querySelectorAll('div.message-bubble, [data-testid="message-bubble"]');
|
|
if (bubbles.length < 2) return '';
|
|
const last = bubbles[bubbles.length - 1];
|
|
const text = (last.innerText || '').trim();
|
|
if (!text || text.length < 2) return '';
|
|
return text;
|
|
})()`);
|
|
|
|
if (response && response.length > 2) {
|
|
if (response === lastText) {
|
|
stableCount++;
|
|
if (stableCount >= 2) return [{ response }];
|
|
} else {
|
|
stableCount = 0;
|
|
}
|
|
}
|
|
lastText = response || '';
|
|
}
|
|
|
|
if (lastText) return [{ response: lastText }];
|
|
return [{ response: NO_RESPONSE_PREFIX }];
|
|
}
|
|
|
|
async function getBubbleTexts(page: IPage): Promise<string[]> {
|
|
const result = await page.evaluate(`(() => {
|
|
return Array.from(document.querySelectorAll(${JSON.stringify(RESPONSE_SELECTOR)}))
|
|
.map(node => (node instanceof HTMLElement ? node.innerText : node?.textContent || ''))
|
|
.map(text => (typeof text === 'string' ? text.trim() : ''))
|
|
.filter(Boolean);
|
|
})()`);
|
|
|
|
return Array.isArray(result) ? result.map(normalizeBubbleText).filter(Boolean) : [];
|
|
}
|
|
|
|
async function tryStartFreshChat(page: IPage): Promise<void> {
|
|
await page.evaluate(`(() => {
|
|
const isVisible = (node) => {
|
|
if (!(node instanceof HTMLElement)) return false;
|
|
const rect = node.getBoundingClientRect();
|
|
const style = window.getComputedStyle(node);
|
|
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
|
|
};
|
|
|
|
const candidates = Array.from(document.querySelectorAll('a, button')).filter(node => {
|
|
if (!isVisible(node)) return false;
|
|
const text = (node.textContent || '').trim().toLowerCase();
|
|
const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
|
|
const href = node.getAttribute('href') || '';
|
|
return text.includes('new chat')
|
|
|| text.includes('new conversation')
|
|
|| aria.includes('new chat')
|
|
|| aria.includes('new conversation')
|
|
|| href === '/';
|
|
});
|
|
|
|
const target = candidates[0];
|
|
if (target instanceof HTMLElement) target.click();
|
|
})()`);
|
|
}
|
|
|
|
async function sendPromptViaExplicitWeb(page: IPage, prompt: string) {
|
|
return page.evaluate(`(async () => {
|
|
const waitFor = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
const composerSelector = '.ProseMirror[contenteditable="true"]';
|
|
let composer = null;
|
|
|
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
const candidate = document.querySelector(composerSelector);
|
|
if (candidate instanceof HTMLElement) {
|
|
composer = candidate;
|
|
break;
|
|
}
|
|
|
|
await waitFor(1000);
|
|
}
|
|
|
|
if (!(composer instanceof HTMLElement)) {
|
|
return {
|
|
ok: false,
|
|
reason: 'Grok composer was not found on grok.com.',
|
|
};
|
|
}
|
|
|
|
const editor = composer.editor;
|
|
if (!editor?.commands?.focus || !editor?.commands?.insertContent) {
|
|
return {
|
|
ok: false,
|
|
reason: 'Grok composer editor API was unavailable.',
|
|
};
|
|
}
|
|
|
|
const isVisibleEnabledSubmit = (node) => {
|
|
if (!(node instanceof HTMLButtonElement)) return false;
|
|
const rect = node.getBoundingClientRect();
|
|
const style = window.getComputedStyle(node);
|
|
return !node.disabled
|
|
&& rect.width > 0
|
|
&& rect.height > 0
|
|
&& style.visibility !== 'hidden'
|
|
&& style.display !== 'none';
|
|
};
|
|
|
|
try {
|
|
if (editor.commands.clearContent) editor.commands.clearContent();
|
|
editor.commands.focus();
|
|
editor.commands.insertContent(${JSON.stringify(prompt)});
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
reason: 'Failed to insert the prompt into the Grok composer.',
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
|
|
let submit = null;
|
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
const candidate = Array.from(document.querySelectorAll('button[aria-label="Submit"]'))
|
|
.find(isVisibleEnabledSubmit);
|
|
|
|
if (candidate instanceof HTMLButtonElement) {
|
|
submit = candidate;
|
|
break;
|
|
}
|
|
|
|
await waitFor(500);
|
|
}
|
|
|
|
if (!(submit instanceof HTMLButtonElement)) {
|
|
return {
|
|
ok: false,
|
|
reason: 'Grok submit button did not reach a clickable ready state after prompt insertion.',
|
|
};
|
|
}
|
|
|
|
submit.click();
|
|
return { ok: true };
|
|
})()`);
|
|
}
|
|
|
|
async function runExplicitWebAsk(
|
|
page: IPage,
|
|
prompt: string,
|
|
timeoutMs: number,
|
|
newChat: boolean,
|
|
) {
|
|
if (newChat) {
|
|
// Navigate to homepage and start a fresh conversation
|
|
await page.goto(GROK_URL, { settleMs: 2000 });
|
|
await tryStartFreshChat(page);
|
|
await page.wait(2);
|
|
} else if (!(await isOnGrok(page))) {
|
|
// First invocation or tab was recycled — navigate to Grok
|
|
await page.goto(GROK_URL, { settleMs: 2000 });
|
|
}
|
|
|
|
const baselineBubbles = await getBubbleTexts(page);
|
|
const sendResult = await sendPromptViaExplicitWeb(page, prompt) as GrokSendResult;
|
|
|
|
if (!sendResult?.ok) {
|
|
const details = sendResult?.detail ? ` ${sendResult.detail}` : '';
|
|
return blocked(`${sendResult?.reason || 'Unable to send the prompt to Grok.'}${details}`);
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
let lastText = '';
|
|
let stableCount = 0;
|
|
|
|
while (Date.now() - startTime < timeoutMs) {
|
|
await page.wait(2);
|
|
const bubbleTexts = await getBubbleTexts(page);
|
|
const candidate = pickLatestAssistantCandidate(bubbleTexts, baselineBubbles.length, prompt);
|
|
const nextState = updateStableState(lastText, stableCount, candidate);
|
|
lastText = nextState.previousText;
|
|
stableCount = nextState.stableCount;
|
|
|
|
if (candidate && stableCount >= 2) {
|
|
return [{ response: candidate }];
|
|
}
|
|
}
|
|
|
|
if (lastText) return [{ response: lastText }];
|
|
return [{ response: `${NO_RESPONSE_PREFIX} No new assistant message bubble appeared within ${Math.round(timeoutMs / 1000)}s.` }];
|
|
}
|
|
|
|
export const askCommand = cli({
|
|
site: 'grok',
|
|
name: 'ask',
|
|
description: 'Send a message to Grok and get response',
|
|
domain: 'grok.com',
|
|
strategy: Strategy.COOKIE,
|
|
browser: true,
|
|
args: [
|
|
{ name: 'prompt', positional: true, type: 'string', required: true, help: 'Prompt to send to Grok' },
|
|
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response (default: 120)' },
|
|
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending (default: false)' },
|
|
{ name: 'web', type: 'boolean', default: false, help: 'Use the explicit grok.com consumer web flow (default: false)' },
|
|
],
|
|
columns: ['response'],
|
|
func: async (page: IPage, kwargs: Record<string, any>) => {
|
|
const prompt = kwargs.prompt as string;
|
|
const timeoutMs = ((kwargs.timeout as number) || 120) * 1000;
|
|
const newChat = normalizeBooleanFlag(kwargs.new);
|
|
const useExplicitWeb = normalizeBooleanFlag(kwargs.web);
|
|
|
|
if (useExplicitWeb) {
|
|
return runExplicitWebAsk(page, prompt, timeoutMs, newChat);
|
|
}
|
|
|
|
return runDefaultAsk(page, prompt, timeoutMs, newChat);
|
|
},
|
|
});
|
|
|
|
export const __test__ = {
|
|
pickLatestAssistantCandidate,
|
|
updateStableState,
|
|
normalizeBooleanFlag,
|
|
normalizeBubbleText,
|
|
isOnGrok,
|
|
};
|