feat(twitter): implement post, reply, delete, following, followers and notifications adapters
This commit is contained in:
1 parent
ee6669f45d
commit
1aaafed2db
12 files changed
+1346
-14
No files matched your search
@@ -9,7 +9,7 @@
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
A CLI tool that turns **any website** into a command-line interface. **47 commands** across **17 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery.
|
||||
A CLI tool that turns **any website** into a command-line interface. **56 commands** across **17 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery.
|
||||
|
||||
---
|
||||
|
||||
@@ -126,7 +126,7 @@ npm install -g @jackwener/opencli@latest
|
||||
| **zhihu** | `hot` `search` `question` | 🔐 Browser |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 🔐 Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` | 🔐 Browser |
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `following` `followers` `notifications` `post` `reply` `delete` | 🔐 Browser |
|
||||
| **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 Browser |
|
||||
| **weibo** | `hot` | 🔐 Browser |
|
||||
| **boss** | `search` | 🔐 Browser |
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatBrowserConnectError, getTokenFingerprint } from './browser.js';
|
||||
|
||||
describe('getTokenFingerprint', () => {
|
||||
it('returns null for empty token', () => {
|
||||
expect(getTokenFingerprint(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stable short fingerprint for token', () => {
|
||||
expect(getTokenFingerprint('abc123')).toBe('6ca13d52');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBrowserConnectError', () => {
|
||||
it('explains missing extension token clearly', () => {
|
||||
const err = formatBrowserConnectError({
|
||||
kind: 'missing-token',
|
||||
mode: 'extension',
|
||||
timeout: 30,
|
||||
hasExtensionToken: false,
|
||||
});
|
||||
|
||||
expect(err.message).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set');
|
||||
expect(err.message).toContain('manual approval dialog');
|
||||
});
|
||||
|
||||
it('mentions token mismatch as likely cause for extension timeout', () => {
|
||||
const err = formatBrowserConnectError({
|
||||
kind: 'extension-timeout',
|
||||
mode: 'extension',
|
||||
timeout: 30,
|
||||
hasExtensionToken: true,
|
||||
tokenFingerprint: 'deadbeef',
|
||||
});
|
||||
|
||||
expect(err.message).toContain('does not match the token currently shown by the browser extension');
|
||||
expect(err.message).toContain('deadbeef');
|
||||
});
|
||||
|
||||
it('keeps CDP timeout guidance separate', () => {
|
||||
const err = formatBrowserConnectError({
|
||||
kind: 'cdp-timeout',
|
||||
mode: 'cdp',
|
||||
timeout: 30,
|
||||
hasExtensionToken: false,
|
||||
});
|
||||
|
||||
expect(err.message).toContain('via CDP');
|
||||
expect(err.message).toContain('chrome://inspect#remote-debugging');
|
||||
});
|
||||
});
|
||||
+164
-12
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -33,7 +34,7 @@ function isPortReachable(port: number, host = '127.0.0.1', timeoutMs = 800): Pro
|
||||
});
|
||||
}
|
||||
|
||||
async function discoverChromeEndpoint(): Promise<string | null> {
|
||||
export async function discoverChromeEndpoint(): Promise<string | null> {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// User-specified Chrome data dir takes highest priority
|
||||
@@ -84,6 +85,106 @@ const EXTENSION_LOCK_POLL = parseInt(process.env.OPENCLI_EXTENSION_LOCK_POLL_INT
|
||||
const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
|
||||
const LOCK_DIR = path.join(os.tmpdir(), 'opencli-mcp-lock');
|
||||
|
||||
type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown';
|
||||
|
||||
type ConnectFailureInput = {
|
||||
kind: ConnectFailureKind;
|
||||
mode: 'extension' | 'cdp';
|
||||
timeout: number;
|
||||
hasExtensionToken: boolean;
|
||||
tokenFingerprint?: string | null;
|
||||
stderr?: string;
|
||||
exitCode?: number | null;
|
||||
rawMessage?: string;
|
||||
};
|
||||
|
||||
export function getTokenFingerprint(token: string | undefined): string | null {
|
||||
if (!token) return null;
|
||||
return createHash('sha256').update(token).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
|
||||
const stderr = input.stderr?.trim();
|
||||
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
|
||||
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
|
||||
|
||||
if (input.mode === 'extension') {
|
||||
if (input.kind === 'missing-token') {
|
||||
return new Error(
|
||||
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
|
||||
'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
|
||||
'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'extension-not-installed') {
|
||||
return new Error(
|
||||
'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
|
||||
'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
|
||||
'If Chrome shows an approval dialog, click Allow.' +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'extension-timeout') {
|
||||
const likelyCause = input.hasExtensionToken
|
||||
? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
|
||||
: 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
|
||||
return new Error(
|
||||
`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
|
||||
`${likelyCause} If a browser prompt is visible, click Allow. You can also switch to Chrome remote debugging mode with OPENCLI_USE_CDP=1 as a fallback.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.mode === 'cdp' && input.kind === 'cdp-timeout') {
|
||||
return new Error(
|
||||
`Timed out connecting to browser via CDP (${input.timeout}s).\n\n` +
|
||||
'Make sure Chrome is running and remote debugging is enabled at chrome://inspect#remote-debugging, or set OPENCLI_CDP_ENDPOINT explicitly.' +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.kind === 'mcp-init') {
|
||||
return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
|
||||
}
|
||||
|
||||
if (input.kind === 'process-exit') {
|
||||
return new Error(
|
||||
`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
|
||||
return new Error(input.rawMessage ?? 'Failed to connect to browser');
|
||||
}
|
||||
|
||||
function inferConnectFailureKind(args: {
|
||||
mode: 'extension' | 'cdp';
|
||||
hasExtensionToken: boolean;
|
||||
stderr: string;
|
||||
rawMessage?: string;
|
||||
exited?: boolean;
|
||||
}): ConnectFailureKind {
|
||||
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
|
||||
|
||||
if (args.mode === 'extension' && !args.hasExtensionToken)
|
||||
return 'missing-token';
|
||||
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
|
||||
return 'extension-not-installed';
|
||||
if (args.rawMessage?.startsWith('MCP init failed:'))
|
||||
return 'mcp-init';
|
||||
if (args.exited)
|
||||
return 'process-exit';
|
||||
if (args.mode === 'extension')
|
||||
return 'extension-timeout';
|
||||
if (args.mode === 'cdp')
|
||||
return 'cdp-timeout';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// JSON-RPC helpers
|
||||
let _nextId = 1;
|
||||
function jsonRpcRequest(method: string, params: Record<string, any> = {}): string {
|
||||
@@ -350,10 +451,42 @@ export class PlaywrightMCP {
|
||||
return new Promise<Page>((resolve, reject) => {
|
||||
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
||||
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
||||
const mode: 'extension' | 'cdp' = cdpEndpoint ? 'cdp' : 'extension';
|
||||
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
||||
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
||||
let stderrBuffer = '';
|
||||
let settled = false;
|
||||
|
||||
const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(formatBrowserConnectError({
|
||||
kind,
|
||||
mode,
|
||||
timeout,
|
||||
hasExtensionToken: !!extensionToken,
|
||||
tokenFingerprint,
|
||||
stderr: stderrBuffer,
|
||||
exitCode: extra.exitCode,
|
||||
rawMessage: extra.rawMessage,
|
||||
}));
|
||||
};
|
||||
|
||||
const settleSuccess = (pageToResolve: Page) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(pageToResolve);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
debugLog('Connection timed out');
|
||||
reject(new Error(`Timed out connecting to browser (${timeout}s)`));
|
||||
settleError(inferConnectFailureKind({
|
||||
mode,
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
}));
|
||||
}, timeout * 1000);
|
||||
|
||||
const mcpArgs: string[] = [mcpPath];
|
||||
@@ -364,6 +497,9 @@ export class PlaywrightMCP {
|
||||
}
|
||||
if (process.env.OPENCLI_VERBOSE) {
|
||||
console.error(`[opencli] CDP mode: ${cdpEndpoint ? `auto-discovered ${cdpEndpoint}` : 'fallback to --extension'}`);
|
||||
if (mode === 'extension') {
|
||||
console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
|
||||
}
|
||||
}
|
||||
if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
|
||||
mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
|
||||
@@ -402,14 +538,25 @@ export class PlaywrightMCP {
|
||||
}
|
||||
});
|
||||
|
||||
this._proc.stderr?.on('data', (chunk) => debugLog(`STDERR: ${chunk}`));
|
||||
this._proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
stderrBuffer += text;
|
||||
debugLog(`STDERR: ${text}`);
|
||||
});
|
||||
this._proc.on('error', (err) => {
|
||||
debugLog(`Subprocess error: ${err.message}`);
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
settleError('process-exit', { rawMessage: err.message });
|
||||
});
|
||||
this._proc.on('close', (code) => {
|
||||
debugLog(`Subprocess closed with code ${code}`);
|
||||
if (!settled) {
|
||||
settleError(inferConnectFailureKind({
|
||||
mode,
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
exited: true,
|
||||
}), { exitCode: code });
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize: send initialize request
|
||||
@@ -426,7 +573,15 @@ export class PlaywrightMCP {
|
||||
debugLog('Waiting for initialize response...');
|
||||
origRecv().then((resp) => {
|
||||
debugLog('Got initialize response');
|
||||
if (resp.error) { clearTimeout(timer); reject(new Error(`MCP init failed: ${resp.error.message}`)); return; }
|
||||
if (resp.error) {
|
||||
settleError(inferConnectFailureKind({
|
||||
mode,
|
||||
hasExtensionToken: !!extensionToken,
|
||||
stderr: stderrBuffer,
|
||||
rawMessage: `MCP init failed: ${resp.error.message}`,
|
||||
}), { rawMessage: resp.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
|
||||
debugLog(`SEND: ${initializedMsg.trim()}`);
|
||||
@@ -441,17 +596,14 @@ export class PlaywrightMCP {
|
||||
} else if (Array.isArray(tabs)) {
|
||||
this._initialTabCount = tabs.length;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
resolve(page);
|
||||
settleSuccess(page);
|
||||
}).catch((err) => {
|
||||
debugLog(`Tabs fetch error: ${err.message}`);
|
||||
clearTimeout(timer);
|
||||
resolve(page);
|
||||
settleSuccess(page);
|
||||
});
|
||||
}).catch((err) => {
|
||||
debugLog(`Init promise rejected: ${err.message}`);
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
settleError('mcp-init', { rawMessage: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'delete',
|
||||
description: 'Delete a specific tweet by URL',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI, // Utilizes internal DOM flows for interaction
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'url', type: 'string', required: true, help: 'The URL of the tweet to delete' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
console.log(`Navigating to tweet: ${kwargs.url}`);
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5); // Wait for tweet to load completely
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
// Wait for caret button (which has 'More' aria-label) within the main tweet body
|
||||
// Getting the first 'More' usually corresponds to the main displayed tweet of the URL
|
||||
const moreMenu = document.querySelector('[aria-label="More"]');
|
||||
if (!moreMenu) {
|
||||
return { ok: false, message: 'Could not find the "More" context menu on this tweet. Are you sure you are logged in and looking at a valid tweet?' };
|
||||
}
|
||||
|
||||
// Click the 'More' 3 dots button to open the dropdown menu
|
||||
moreMenu.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Wait for dropdown pop-out to appear and look for the 'Delete' option
|
||||
const items = document.querySelectorAll('[role="menuitem"]');
|
||||
let deleteBtn = null;
|
||||
for (const item of items) {
|
||||
if (item.textContent.includes('Delete') && !item.textContent.includes('List')) {
|
||||
deleteBtn = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleteBtn) {
|
||||
// If there's no Delete button, it's not our tweet OR localization is not English.
|
||||
// Assuming English default for now.
|
||||
return { ok: false, message: 'This tweet does not seem to belong to you, or the Delete option is missing (not your tweet).' };
|
||||
}
|
||||
|
||||
// Click Delete
|
||||
deleteBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Find and click the confirmation 'Delete' prompt inside the modal
|
||||
const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
|
||||
if (confirmBtn) {
|
||||
confirmBtn.click();
|
||||
return { ok: true, message: 'Tweet successfully deleted.' };
|
||||
} else {
|
||||
return { ok: false, message: 'Delete confirmation dialog did not appear.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result.ok) {
|
||||
// Wait for the deletion request to be processed
|
||||
await page.wait(2);
|
||||
}
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message
|
||||
}];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'followers',
|
||||
description: 'Get accounts following a Twitter/X user',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'user', type: 'string', required: false },
|
||||
{ name: 'limit', type: 'int', default: 50 },
|
||||
],
|
||||
columns: ['screen_name', 'name', 'bio', 'followers'],
|
||||
func: async (page, kwargs) => {
|
||||
let targetUser = kwargs.user;
|
||||
|
||||
// If no user is specified, we must figure out the logged-in user's handle
|
||||
if (!targetUser) {
|
||||
await page.goto('https://x.com/home');
|
||||
// wait for home page navigation
|
||||
await page.wait(5);
|
||||
|
||||
const href = await page.evaluate(`() => {
|
||||
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
||||
return link ? link.getAttribute('href') : null;
|
||||
}`);
|
||||
|
||||
if (!href) {
|
||||
throw new Error('Could not find logged-in user profile link. Are you logged in?');
|
||||
}
|
||||
targetUser = href.replace('/', '');
|
||||
}
|
||||
|
||||
// 1. Navigate to user profile page
|
||||
await page.goto(`https://x.com/${targetUser}`);
|
||||
await page.wait(3);
|
||||
|
||||
// 2. Inject interceptor for Followers GraphQL API (or user_flow.json)
|
||||
await page.installInterceptor('graphql');
|
||||
|
||||
// 3. Click the followers link inside the profile page
|
||||
await page.evaluate(`() => {
|
||||
const target = '${targetUser}';
|
||||
const link = document.querySelector('a[href="/' + target + '/followers"]');
|
||||
if (link) link.click();
|
||||
}`);
|
||||
await page.wait(3);
|
||||
|
||||
// 4. Trigger API by scrolling
|
||||
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
||||
|
||||
// 4. Retrieve data from opencli's registered interceptors
|
||||
const allRequests = await page.getInterceptedRequests();
|
||||
|
||||
// Debug: Force dump all intercepted XHRs that match followers
|
||||
if (!allRequests || allRequests.length === 0) {
|
||||
console.log('No GraphQL requests captured by the interceptor backend.');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('Intercepted keys:', allRequests.map((r: any) => {
|
||||
try {
|
||||
const u = new URL(r.url); return u.pathname;
|
||||
} catch (e) {
|
||||
return r.url;
|
||||
}
|
||||
}));
|
||||
|
||||
const requests = allRequests.filter((r: any) => r.url.includes('Followers'));
|
||||
if (!requests || requests.length === 0) {
|
||||
console.log('No specific Followers requests captured. Check keys printed above.');
|
||||
return [];
|
||||
}
|
||||
|
||||
let results: any[] = [];
|
||||
for (const req of requests) {
|
||||
try {
|
||||
let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
|
||||
if (!instructions) continue;
|
||||
|
||||
let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
|
||||
if (!addEntries) {
|
||||
addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
|
||||
}
|
||||
|
||||
if (!addEntries) continue;
|
||||
|
||||
for (const entry of addEntries.entries) {
|
||||
if (!entry.entryId.startsWith('user-')) continue;
|
||||
|
||||
const item = entry.content?.itemContent?.user_results?.result;
|
||||
if (!item || item.__typename !== 'User') continue;
|
||||
|
||||
// Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
|
||||
const core = item.core || {};
|
||||
const legacy = item.legacy || {};
|
||||
|
||||
results.push({
|
||||
screen_name: core.screen_name || legacy.screen_name || 'unknown',
|
||||
name: core.name || legacy.name || 'unknown',
|
||||
bio: legacy.description || item.profile_bio?.description || '',
|
||||
followers: legacy.followers_count || legacy.normal_followers_count || 0
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parsing errors for individual payloads
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by screen_name in case multiple scrolls caught the same
|
||||
const unique = new Map();
|
||||
results.forEach(r => unique.set(r.screen_name, r));
|
||||
const deduplicatedResults = Array.from(unique.values());
|
||||
|
||||
return deduplicatedResults.slice(0, kwargs.limit);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'following',
|
||||
description: 'Get accounts a Twitter/X user is following',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'user', type: 'string', required: false },
|
||||
{ name: 'limit', type: 'int', default: 50 },
|
||||
],
|
||||
columns: ['screen_name', 'name', 'bio', 'followers'],
|
||||
func: async (page, kwargs) => {
|
||||
let targetUser = kwargs.user;
|
||||
|
||||
// If no user is specified, we must figure out the logged-in user's handle
|
||||
if (!targetUser) {
|
||||
await page.goto('https://x.com/home');
|
||||
// wait for home page navigation
|
||||
await page.wait(5);
|
||||
|
||||
const href = await page.evaluate(`() => {
|
||||
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
||||
return link ? link.getAttribute('href') : null;
|
||||
}`);
|
||||
|
||||
if (!href) {
|
||||
throw new Error('Could not find logged-in user profile link. Are you logged in?');
|
||||
}
|
||||
targetUser = href.replace('/', '');
|
||||
}
|
||||
|
||||
// 1. Navigate to user profile page
|
||||
await page.goto(`https://x.com/${targetUser}`);
|
||||
await page.wait(3);
|
||||
|
||||
// 2. Inject interceptor for Following GraphQL API
|
||||
await page.installInterceptor('Following');
|
||||
|
||||
// 3. Click the following link inside the profile page
|
||||
await page.evaluate(`() => {
|
||||
const target = '${targetUser}';
|
||||
const link = document.querySelector('a[href="/' + target + '/following"]');
|
||||
if (link) link.click();
|
||||
}`);
|
||||
await page.wait(3);
|
||||
|
||||
// 4. Trigger API by scrolling
|
||||
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
||||
|
||||
// 4. Retrieve data from opencli's registered interceptors
|
||||
const requests = await page.getInterceptedRequests();
|
||||
|
||||
// Debug: Force dump all intercepted XHRs that match following
|
||||
if (!requests || requests.length === 0) {
|
||||
console.log('No Following requests captured by the interceptor backend.');
|
||||
return [];
|
||||
}
|
||||
|
||||
let results: any[] = [];
|
||||
for (const req of requests) {
|
||||
try {
|
||||
let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
|
||||
if (!instructions) continue;
|
||||
|
||||
let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
|
||||
if (!addEntries) {
|
||||
addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
|
||||
}
|
||||
|
||||
if (!addEntries) continue;
|
||||
|
||||
for (const entry of addEntries.entries) {
|
||||
if (!entry.entryId.startsWith('user-')) continue;
|
||||
|
||||
const item = entry.content?.itemContent?.user_results?.result;
|
||||
if (!item || item.__typename !== 'User') continue;
|
||||
|
||||
// Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
|
||||
const core = item.core || {};
|
||||
const legacy = item.legacy || {};
|
||||
|
||||
results.push({
|
||||
screen_name: core.screen_name || legacy.screen_name || 'unknown',
|
||||
name: core.name || legacy.name || 'unknown',
|
||||
bio: legacy.description || item.profile_bio?.description || '',
|
||||
followers: legacy.followers_count || legacy.normal_followers_count || 0
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parsing errors for individual payloads
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by screen_name in case multiple scrolls caught the same
|
||||
const unique = new Map();
|
||||
results.forEach(r => unique.set(r.screen_name, r));
|
||||
const deduplicatedResults = Array.from(unique.values());
|
||||
|
||||
return deduplicatedResults.slice(0, kwargs.limit);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'notifications',
|
||||
description: 'Get Twitter/X notifications',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20 },
|
||||
],
|
||||
columns: ['id', 'action', 'author', 'text', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
// 1. Navigate to notifications
|
||||
await page.goto('https://x.com/notifications');
|
||||
await page.wait(5);
|
||||
|
||||
// 2. Inject interceptor
|
||||
await page.installInterceptor('NotificationsTimeline');
|
||||
|
||||
// 3. Trigger API by scrolling (if we need to load more)
|
||||
await page.autoScroll({ times: 2, delayMs: 2000 });
|
||||
|
||||
// 4. Retrieve data
|
||||
const requests = await page.getInterceptedRequests();
|
||||
if (!requests || requests.length === 0) return [];
|
||||
|
||||
let results: any[] = [];
|
||||
for (const req of requests) {
|
||||
try {
|
||||
let instructions = [];
|
||||
if (req.data?.data?.viewer?.timeline_response?.timeline?.instructions) {
|
||||
instructions = req.data.data.viewer.timeline_response.timeline.instructions;
|
||||
} else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
|
||||
instructions = req.data.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
|
||||
} else if (req.data?.data?.timeline?.instructions) {
|
||||
instructions = req.data.data.timeline.instructions;
|
||||
}
|
||||
|
||||
let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
|
||||
|
||||
// Sometimes it's the first object without a 'type' field but has 'entries'
|
||||
if (!addEntries) {
|
||||
addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
|
||||
}
|
||||
|
||||
if (!addEntries) continue;
|
||||
|
||||
for (const entry of addEntries.entries) {
|
||||
if (!entry.entryId.startsWith('notification-')) {
|
||||
if (entry.content?.items) {
|
||||
for (const subItem of entry.content.items) {
|
||||
processNotificationItem(subItem.item?.itemContent, subItem.entryId);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
processNotificationItem(entry.content?.itemContent, entry.entryId);
|
||||
}
|
||||
|
||||
function processNotificationItem(itemContent: any, entryId: string) {
|
||||
if (!itemContent) return;
|
||||
|
||||
// Twitter wraps standard notifications
|
||||
let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
|
||||
|
||||
let actionText = 'Notification';
|
||||
let author = 'unknown';
|
||||
let text = '';
|
||||
let urlStr = '';
|
||||
|
||||
if (item.__typename === 'TimelineNotification') {
|
||||
// Greet likes, retweet, mentions
|
||||
text = item.rich_message?.text || item.message?.text || '';
|
||||
author = item.template?.from_users?.[0]?.user_results?.result?.core?.screen_name || 'unknown';
|
||||
urlStr = item.notification_url?.url || '';
|
||||
actionText = item.notification_icon || 'Activity';
|
||||
|
||||
// If there's an attached tweet
|
||||
const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
|
||||
if (targetTweet) {
|
||||
text += ' | ' + (targetTweet.legacy?.full_text || '');
|
||||
if (!urlStr) {
|
||||
urlStr = `https://x.com/i/status/${targetTweet.rest_id}`;
|
||||
}
|
||||
}
|
||||
} else if (item.__typename === 'TweetNotification') {
|
||||
// Direct mention/reply
|
||||
const tweet = item.tweet_result?.result;
|
||||
author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown';
|
||||
text = tweet?.legacy?.full_text || item.message?.text || '';
|
||||
actionText = 'Mention/Reply';
|
||||
urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
|
||||
} else if (item.__typename === 'Tweet') {
|
||||
author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown';
|
||||
text = item.legacy?.full_text || '';
|
||||
actionText = 'Mention';
|
||||
urlStr = `https://x.com/i/status/${item.rest_id}`;
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: item.id || item.rest_id || entryId,
|
||||
action: actionText,
|
||||
author: author,
|
||||
text: text,
|
||||
url: urlStr || `https://x.com/notifications`
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parsing errors for individual payloads
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, kwargs.limit);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'post',
|
||||
description: 'Post a new tweet/thread',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'text', type: 'string', required: true, help: 'The text content of the tweet' },
|
||||
],
|
||||
columns: ['status', 'message', 'text'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
// 1. Navigate directly to the compose tweet modal
|
||||
await page.goto('https://x.com/compose/tweet');
|
||||
await page.wait(3); // Wait for the modal and React app to hydrate
|
||||
|
||||
// 2. Automate typing and clicking
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
// Find the active text area
|
||||
const box = document.querySelector('[data-testid="tweetTextarea_0"]');
|
||||
if (box) {
|
||||
box.focus();
|
||||
// insertText is the most reliable way to trigger React's onChange events
|
||||
document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
|
||||
} else {
|
||||
return { ok: false, message: 'Could not find the tweet composer text area.' };
|
||||
}
|
||||
|
||||
// Wait a brief moment for the button state to update
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Click the post button
|
||||
const btn = document.querySelector('[data-testid="tweetButton"]');
|
||||
if (btn && !btn.disabled) {
|
||||
btn.click();
|
||||
return { ok: true, message: 'Tweet posted successfully.' };
|
||||
} else {
|
||||
// Sometimes it's rendered inline depending on the viewport
|
||||
const inlineBtn = document.querySelector('[data-testid="tweetButtonInline"]');
|
||||
if (inlineBtn && !inlineBtn.disabled) {
|
||||
inlineBtn.click();
|
||||
return { ok: true, message: 'Tweet posted successfully.' };
|
||||
}
|
||||
return { ok: false, message: 'Tweet button is disabled or not found.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
// 3. Wait a few seconds for the network request to finish sending
|
||||
if (result.ok) {
|
||||
await page.wait(3);
|
||||
}
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message,
|
||||
text: kwargs.text
|
||||
}];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'reply',
|
||||
description: 'Reply to a specific tweet',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI, // Uses the UI directly to input and click post
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'url', type: 'string', required: true, help: 'The URL of the tweet to reply to' },
|
||||
{ name: 'text', type: 'string', required: true, help: 'The text content of your reply' },
|
||||
],
|
||||
columns: ['status', 'message', 'text'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
// 1. Navigate to the tweet page
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5); // Wait for the react application to hydrate
|
||||
|
||||
// 2. Automate typing the reply and clicking reply
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
// Find the reply text area on the tweet page.
|
||||
// The placeholder is usually "Post your reply"
|
||||
const box = document.querySelector('[data-testid="tweetTextarea_0"]');
|
||||
if (box) {
|
||||
box.focus();
|
||||
document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
|
||||
} else {
|
||||
return { ok: false, message: 'Could not find the reply text area. Are you logged in?' };
|
||||
}
|
||||
|
||||
// Wait for React state to register the input and enable the button
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Find the Reply button. It usually shares the same test id tweetButtonInline in this context
|
||||
const btn = document.querySelector('[data-testid="tweetButtonInline"]');
|
||||
if (btn && !btn.disabled) {
|
||||
btn.click();
|
||||
return { ok: true, message: 'Reply posted successfully.' };
|
||||
} else {
|
||||
return { ok: false, message: 'Reply button is disabled or not found.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result.ok) {
|
||||
await page.wait(3); // Wait for network submission to complete
|
||||
}
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message,
|
||||
text: kwargs.text
|
||||
}];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
readTokenFromShellContent,
|
||||
renderBrowserDoctorReport,
|
||||
upsertShellToken,
|
||||
readTomlConfigToken,
|
||||
upsertTomlConfigToken,
|
||||
upsertJsonConfigToken,
|
||||
} from './doctor.js';
|
||||
|
||||
describe('shell token helpers', () => {
|
||||
it('reads token from shell export', () => {
|
||||
expect(readTokenFromShellContent('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"\n')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('appends token export when missing', () => {
|
||||
const next = upsertShellToken('export PATH="/usr/bin"\n', 'abc123');
|
||||
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
|
||||
});
|
||||
|
||||
it('replaces token export when present', () => {
|
||||
const next = upsertShellToken('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="old"\n', 'new');
|
||||
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="new"');
|
||||
expect(next).not.toContain('"old"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toml token helpers', () => {
|
||||
it('reads token from playwright env section', () => {
|
||||
const content = `
|
||||
[mcp_servers.playwright.env]
|
||||
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"
|
||||
`;
|
||||
expect(readTomlConfigToken(content)).toBe('abc123');
|
||||
});
|
||||
|
||||
it('updates token inside existing env section', () => {
|
||||
const content = `
|
||||
[mcp_servers.playwright.env]
|
||||
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "old"
|
||||
`;
|
||||
const next = upsertTomlConfigToken(content, 'new');
|
||||
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "new"');
|
||||
expect(next).not.toContain('"old"');
|
||||
});
|
||||
|
||||
it('creates env section when missing', () => {
|
||||
const content = `
|
||||
[mcp_servers.playwright]
|
||||
type = "stdio"
|
||||
`;
|
||||
const next = upsertTomlConfigToken(content, 'abc123');
|
||||
expect(next).toContain('[mcp_servers.playwright.env]');
|
||||
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('json token helpers', () => {
|
||||
it('writes token into standard mcpServers config', () => {
|
||||
const next = upsertJsonConfigToken(JSON.stringify({
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
||||
},
|
||||
},
|
||||
}), 'abc123');
|
||||
const parsed = JSON.parse(next);
|
||||
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
||||
});
|
||||
|
||||
it('writes token into opencode mcp config', () => {
|
||||
const next = upsertJsonConfigToken(JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
mcp: {
|
||||
playwright: {
|
||||
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
||||
enabled: true,
|
||||
type: 'local',
|
||||
},
|
||||
},
|
||||
}), 'abc123');
|
||||
const parsed = JSON.parse(next);
|
||||
expect(parsed.mcp.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('doctor report rendering', () => {
|
||||
it('renders OK-style report when tokens match', () => {
|
||||
const text = renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
envFingerprint: 'fp1',
|
||||
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
||||
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
||||
remoteDebuggingEnabled: true,
|
||||
remoteDebuggingEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test',
|
||||
cdpEnabled: false,
|
||||
cdpToken: null,
|
||||
cdpFingerprint: null,
|
||||
recommendedToken: 'abc123',
|
||||
recommendedFingerprint: 'fp1',
|
||||
warnings: [],
|
||||
issues: [],
|
||||
});
|
||||
|
||||
expect(text).toContain('[OK] Chrome remote debugging: enabled');
|
||||
expect(text).toContain('[OK] Environment token: configured (fp1)');
|
||||
expect(text).toContain('[OK] MCP config /tmp/mcp.json: configured (fp1)');
|
||||
});
|
||||
|
||||
it('renders MISMATCH-style report when fingerprints differ', () => {
|
||||
const text = renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
envFingerprint: 'fp1',
|
||||
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
||||
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
||||
remoteDebuggingEnabled: false,
|
||||
remoteDebuggingEndpoint: null,
|
||||
cdpEnabled: false,
|
||||
cdpToken: null,
|
||||
cdpFingerprint: null,
|
||||
recommendedToken: 'abc123',
|
||||
recommendedFingerprint: 'fp1',
|
||||
warnings: ['Chrome remote debugging appears to be disabled or Chrome is not currently exposing a DevTools endpoint.'],
|
||||
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
||||
});
|
||||
|
||||
expect(text).toContain('[WARN] Chrome remote debugging: disabled');
|
||||
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
|
||||
expect(text).toContain('[MISMATCH] Shell file /tmp/.zshrc: configured (fp2)');
|
||||
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
||||
});
|
||||
});
|
||||
+421
@@ -0,0 +1,421 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
import type { IPage } from './types.js';
|
||||
import { PlaywrightMCP, discoverChromeEndpoint, getTokenFingerprint } from './browser.js';
|
||||
import { browserSession } from './runtime.js';
|
||||
|
||||
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
||||
const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
|
||||
const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
|
||||
const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
|
||||
export type DoctorOptions = {
|
||||
fix?: boolean;
|
||||
yes?: boolean;
|
||||
shellRc?: string;
|
||||
configPaths?: string[];
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type ShellFileStatus = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
token: string | null;
|
||||
fingerprint: string | null;
|
||||
};
|
||||
|
||||
export type McpConfigFormat = 'json' | 'toml';
|
||||
|
||||
export type McpConfigStatus = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
format: McpConfigFormat;
|
||||
token: string | null;
|
||||
fingerprint: string | null;
|
||||
writable: boolean;
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
export type DoctorReport = {
|
||||
envToken: string | null;
|
||||
envFingerprint: string | null;
|
||||
shellFiles: ShellFileStatus[];
|
||||
configs: McpConfigStatus[];
|
||||
remoteDebuggingEnabled: boolean;
|
||||
remoteDebuggingEndpoint: string | null;
|
||||
cdpEnabled: boolean;
|
||||
cdpToken: string | null;
|
||||
cdpFingerprint: string | null;
|
||||
recommendedToken: string | null;
|
||||
recommendedFingerprint: string | null;
|
||||
warnings: string[];
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN';
|
||||
|
||||
function label(status: ReportStatus): string {
|
||||
return `[${status}]`;
|
||||
}
|
||||
|
||||
function statusLine(status: ReportStatus, text: string): string {
|
||||
return `${label(status)} ${text}`;
|
||||
}
|
||||
|
||||
function tokenSummary(token: string | null, fingerprint: string | null): string {
|
||||
if (!token) return 'missing';
|
||||
return `configured (${fingerprint})`;
|
||||
}
|
||||
|
||||
export function getDefaultShellRcPath(): string {
|
||||
const shell = process.env.SHELL ?? '';
|
||||
if (shell.endsWith('/bash')) return path.join(os.homedir(), '.bashrc');
|
||||
if (shell.endsWith('/fish')) return path.join(os.homedir(), '.config', 'fish', 'config.fish');
|
||||
return path.join(os.homedir(), '.zshrc');
|
||||
}
|
||||
|
||||
export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
|
||||
const home = os.homedir();
|
||||
const candidates = [
|
||||
path.join(home, '.codex', 'config.toml'),
|
||||
path.join(home, '.codex', 'mcp.json'),
|
||||
path.join(home, '.cursor', 'mcp.json'),
|
||||
path.join(home, '.config', 'opencode', 'opencode.json'),
|
||||
path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
||||
path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
|
||||
path.join(cwd, '.cursor', 'mcp.json'),
|
||||
path.join(cwd, '.vscode', 'mcp.json'),
|
||||
path.join(cwd, '.opencode', 'opencode.json'),
|
||||
];
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
export function readTokenFromShellContent(content: string): string | null {
|
||||
const m = content.match(TOKEN_LINE_RE);
|
||||
return m?.[3] ?? null;
|
||||
}
|
||||
|
||||
export function upsertShellToken(content: string, token: string): string {
|
||||
const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
|
||||
if (!content.trim()) return `${nextLine}\n`;
|
||||
if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
|
||||
token
|
||||
}"`);
|
||||
return `${content.replace(/\s*$/, '')}\n${nextLine}\n`;
|
||||
}
|
||||
|
||||
function readJsonConfigToken(content: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return readTokenFromJsonObject(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readTokenFromJsonObject(parsed: any): string | null {
|
||||
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
||||
if (typeof direct === 'string' && direct) return direct;
|
||||
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
||||
if (typeof opencode === 'string' && opencode) return opencode;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function upsertJsonConfigToken(content: string, token: string): string {
|
||||
const parsed = content.trim() ? JSON.parse(content) : {};
|
||||
if (parsed?.mcpServers) {
|
||||
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
|
||||
command: 'npx',
|
||||
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
||||
};
|
||||
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
|
||||
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
} else {
|
||||
parsed.mcp = parsed.mcp ?? {};
|
||||
parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
|
||||
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
||||
enabled: true,
|
||||
type: 'local',
|
||||
};
|
||||
parsed.mcp[PLAYWRIGHT_SERVER_NAME].env = parsed.mcp[PLAYWRIGHT_SERVER_NAME].env ?? {};
|
||||
parsed.mcp[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
}
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function readTomlConfigToken(content: string): string | null {
|
||||
const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/);
|
||||
if (!sectionMatch) return null;
|
||||
const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m);
|
||||
return tokenMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function upsertTomlConfigToken(content: string, token: string): string {
|
||||
const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/;
|
||||
const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`;
|
||||
if (envSectionRe.test(content)) {
|
||||
return content.replace(envSectionRe, (section) => {
|
||||
if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) {
|
||||
return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine);
|
||||
}
|
||||
return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/;
|
||||
if (baseSectionRe.test(content)) {
|
||||
return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`);
|
||||
}
|
||||
|
||||
const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
|
||||
return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
|
||||
}
|
||||
|
||||
function fileExists(filePath: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canWrite(filePath: string): boolean {
|
||||
try {
|
||||
if (fileExists(filePath)) {
|
||||
fs.accessSync(filePath, fs.constants.W_OK);
|
||||
return true;
|
||||
}
|
||||
fs.accessSync(path.dirname(filePath), fs.constants.W_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readConfigStatus(filePath: string): McpConfigStatus {
|
||||
const format: McpConfigFormat = filePath.endsWith('.toml') ? 'toml' : 'json';
|
||||
if (!fileExists(filePath)) {
|
||||
return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) };
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
format,
|
||||
token,
|
||||
fingerprint: getTokenFingerprint(token ?? undefined),
|
||||
writable: canWrite(filePath),
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
format,
|
||||
token: null,
|
||||
fingerprint: null,
|
||||
writable: canWrite(filePath),
|
||||
parseError: error?.message ?? String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function extractTokenViaCdp(): Promise<string | null> {
|
||||
if (!(process.env.OPENCLI_USE_CDP === '1' || process.env.OPENCLI_CDP_ENDPOINT))
|
||||
return null;
|
||||
const candidates = [
|
||||
`chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/options.html`,
|
||||
`chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/popup.html`,
|
||||
`chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/connect.html`,
|
||||
`chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/index.html`,
|
||||
];
|
||||
const result = await browserSession(PlaywrightMCP, async (page: IPage) => {
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
await page.goto(url);
|
||||
await page.wait(1);
|
||||
const token = await page.evaluate(`() => {
|
||||
const values = new Set();
|
||||
const push = (value) => {
|
||||
if (!value || typeof value !== 'string') return;
|
||||
for (const match of value.matchAll(/[A-Za-z0-9_-]{24,}/g)) values.add(match[0]);
|
||||
};
|
||||
document.querySelectorAll('input, textarea, code, pre, span, div').forEach((el) => {
|
||||
push(el.value);
|
||||
push(el.textContent || '');
|
||||
push(el.getAttribute && el.getAttribute('value'));
|
||||
});
|
||||
return Array.from(values);
|
||||
}`);
|
||||
const matches = Array.isArray(token) ? token.filter((v: string) => v.length >= 24) : [];
|
||||
if (matches.length > 0) return matches.sort((a: string, b: string) => b.length - a.length)[0];
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return typeof result === 'string' && result ? result : null;
|
||||
}
|
||||
|
||||
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
||||
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
||||
const remoteDebuggingEndpoint = await discoverChromeEndpoint().catch(() => null);
|
||||
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
||||
const shellFiles: ShellFileStatus[] = [shellPath].map((filePath) => {
|
||||
if (!fileExists(filePath)) return { path: filePath, exists: false, token: null, fingerprint: null };
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const token = readTokenFromShellContent(content);
|
||||
return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
|
||||
});
|
||||
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
|
||||
const configs = configPaths.map(readConfigStatus);
|
||||
const cdpToken = !opts.token && !envToken ? await extractTokenViaCdp().catch(() => null) : null;
|
||||
|
||||
const allTokens = [
|
||||
opts.token ?? null,
|
||||
envToken,
|
||||
...shellFiles.map(s => s.token),
|
||||
...configs.map(c => c.token),
|
||||
cdpToken,
|
||||
].filter((v): v is string => !!v);
|
||||
const uniqueTokens = [...new Set(allTokens)];
|
||||
const recommendedToken = opts.token ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : cdpToken) ?? null;
|
||||
|
||||
const report: DoctorReport = {
|
||||
envToken,
|
||||
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
||||
shellFiles,
|
||||
configs,
|
||||
remoteDebuggingEnabled: !!remoteDebuggingEndpoint,
|
||||
remoteDebuggingEndpoint,
|
||||
cdpEnabled: process.env.OPENCLI_USE_CDP === '1' || !!process.env.OPENCLI_CDP_ENDPOINT,
|
||||
cdpToken,
|
||||
cdpFingerprint: getTokenFingerprint(cdpToken ?? undefined),
|
||||
recommendedToken,
|
||||
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
||||
warnings: [],
|
||||
issues: [],
|
||||
};
|
||||
|
||||
if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
||||
if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
|
||||
if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
||||
if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
||||
if (!report.remoteDebuggingEnabled) report.warnings.push('Chrome remote debugging appears to be disabled or Chrome is not currently exposing a DevTools endpoint.');
|
||||
for (const config of configs) {
|
||||
if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
||||
}
|
||||
if (!recommendedToken) {
|
||||
if (report.cdpEnabled) report.warnings.push('CDP is enabled, but no token could be extracted automatically from the extension UI.');
|
||||
else report.warnings.push('No token source found. Enable OPENCLI_USE_CDP=1 to allow a best-effort token read from the extension page.');
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
const tokenFingerprints = [
|
||||
report.envFingerprint,
|
||||
...report.shellFiles.map(shell => shell.fingerprint),
|
||||
...report.configs.filter(config => config.exists).map(config => config.fingerprint),
|
||||
].filter((value): value is string => !!value);
|
||||
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
||||
const hasMismatch = uniqueFingerprints.length > 1;
|
||||
const lines = ['opencli doctor', ''];
|
||||
lines.push(statusLine(report.remoteDebuggingEnabled ? 'OK' : 'WARN', `Chrome remote debugging: ${report.remoteDebuggingEnabled ? 'enabled' : 'disabled'}`));
|
||||
if (report.remoteDebuggingEndpoint) lines.push(` ${report.remoteDebuggingEndpoint}`);
|
||||
|
||||
const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
||||
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
|
||||
|
||||
for (const shell of report.shellFiles) {
|
||||
const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
||||
lines.push(statusLine(shellStatus, `Shell file ${shell.path}: ${tokenSummary(shell.token, shell.fingerprint)}`));
|
||||
}
|
||||
const existingConfigs = report.configs.filter(config => config.exists);
|
||||
const missingConfigCount = report.configs.length - existingConfigs.length;
|
||||
if (existingConfigs.length > 0) {
|
||||
for (const config of existingConfigs) {
|
||||
const parseSuffix = config.parseError ? ` (parse error: ${config.parseError})` : '';
|
||||
const configStatus: ReportStatus = config.parseError
|
||||
? 'WARN'
|
||||
: !config.token
|
||||
? 'MISSING'
|
||||
: hasMismatch
|
||||
? 'MISMATCH'
|
||||
: 'OK';
|
||||
lines.push(statusLine(configStatus, `MCP config ${config.path}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
|
||||
}
|
||||
} else {
|
||||
lines.push(statusLine('MISSING', 'MCP config: no existing config files found in scanned locations'));
|
||||
}
|
||||
if (missingConfigCount > 0) lines.push(` Other scanned config locations not present: ${missingConfigCount}`);
|
||||
if (report.cdpEnabled) {
|
||||
const cdpStatus: ReportStatus = report.cdpToken ? 'OK' : 'WARN';
|
||||
lines.push(statusLine(cdpStatus, `CDP token probe: ${tokenSummary(report.cdpToken, report.cdpFingerprint)}`));
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(statusLine(
|
||||
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
|
||||
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
|
||||
));
|
||||
if (report.issues.length) {
|
||||
lines.push('', 'Issues:');
|
||||
for (const issue of report.issues) lines.push(`- ${issue}`);
|
||||
}
|
||||
if (report.warnings.length) {
|
||||
lines.push('', 'Warnings:');
|
||||
for (const warning of report.warnings) lines.push(`- ${warning}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function confirmPrompt(question: string): Promise<boolean> {
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
||||
return answer === 'y' || answer === 'yes';
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function writeFileWithMkdir(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
|
||||
const token = opts.token ?? report.recommendedToken;
|
||||
if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token or enable CDP token probing first.');
|
||||
|
||||
const plannedWrites: string[] = [];
|
||||
const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
|
||||
plannedWrites.push(shellPath);
|
||||
for (const config of report.configs) {
|
||||
if (!config.writable) continue;
|
||||
plannedWrites.push(config.path);
|
||||
}
|
||||
|
||||
if (!opts.yes) {
|
||||
const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${getTokenFingerprint(token)}?`);
|
||||
if (!ok) return [];
|
||||
}
|
||||
|
||||
const written: string[] = [];
|
||||
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
|
||||
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
|
||||
written.push(shellPath);
|
||||
|
||||
for (const config of report.configs) {
|
||||
if (!config.writable || config.parseError) continue;
|
||||
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
|
||||
const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
|
||||
writeFileWithMkdir(config.path, next);
|
||||
written.push(config.path);
|
||||
}
|
||||
|
||||
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
|
||||
return written;
|
||||
}
|
||||
+24
@@ -91,6 +91,30 @@ program.command('cascade').description('Strategy cascade: find simplest working
|
||||
console.log(renderCascadeResult(result));
|
||||
});
|
||||
|
||||
program.command('doctor')
|
||||
.description('Diagnose Playwright MCP Bridge, token consistency, and Chrome remote debugging')
|
||||
.option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
|
||||
.option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
|
||||
.option('--token <token>', 'Override token to write instead of auto-detecting')
|
||||
.option('--shell-rc <path>', 'Shell startup file to update')
|
||||
.option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
|
||||
.action(async (opts) => {
|
||||
const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
|
||||
const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined;
|
||||
const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths });
|
||||
console.log(renderBrowserDoctorReport(report));
|
||||
if (opts.fix) {
|
||||
const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
|
||||
console.log();
|
||||
if (written.length > 0) {
|
||||
console.log(chalk.green('Updated files:'));
|
||||
for (const filePath of written) console.log(`- ${filePath}`);
|
||||
} else {
|
||||
console.log(chalk.yellow('No files were changed.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Dynamic site commands ──────────────────────────────────────────────────
|
||||
|
||||
const registry = getRegistry();
|
||||
|
||||
Reference in new issue
Block a user