feat(twitter): implement post, reply, delete, following, followers and notifications adapters

This commit is contained in:
jackwener committed 2026-03-15 21:42:48 +08:00
1 parent ee6669f45d
commit 1aaafed2db
12 files changed
+1346 -14

No files matched your search

+2 -2
View File
@@ -9,7 +9,7 @@
[![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
[![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./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 |
+51
View File
@@ -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
View File
@@ -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 });
});
});
}
+78
View File
@@ -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
}];
}
});
+119
View File
@@ -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);
}
});
+105
View File
@@ -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);
}
});
+119
View File
@@ -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);
}
});
+68
View File
@@ -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
}];
}
});
+62
View File
@@ -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
}];
}
});
+133
View File
@@ -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
View File
@@ -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
View File
@@ -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();