feat: add interactive 'opencli setup' command with TUI checkbox

New zero-dependency TUI checkbox component (src/tui.ts) with:
  - ↑↓/jk navigation, Space toggle, Tab toggle+move
  - 'a' toggle all, Enter confirm, q/Esc cancel

New 'opencli setup' command (src/setup.ts) that:
  - Auto-discovers token from Chrome LevelDB extension
  - Shows interactive multi-select for config files
  - Displays tool names (Codex, Cursor, Claude Code, etc.)
  - Color-coded status (green=ok, yellow=mismatch, red=missing)
  - Applies changes only to selected files
This commit is contained in:
jackwener committed 2026-03-16 13:53:03 +08:00
1 parent 1e0b83bb84
commit 6f629260e7
3 files changed
+360

No files matched your search

+8
View File
@@ -111,6 +111,14 @@ program.command('doctor')
}
});
program.command('setup')
.description('Interactive setup: configure Playwright MCP token across all detected tools')
.option('--token <token>', 'Provide token directly instead of auto-detecting')
.action(async (opts) => {
const { runSetup } = await import('./setup.js');
await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
});
// ── Dynamic site commands ──────────────────────────────────────────────────
const registry = getRegistry();
+187
View File
@@ -0,0 +1,187 @@
/**
* setup.ts — Interactive Playwright MCP token setup
*
* Discovers the extension token, shows an interactive checkbox
* for selecting which config files to update, and applies changes.
*/
import * as fs from 'node:fs';
import chalk from 'chalk';
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import {
type DoctorReport,
discoverExtensionToken,
getDefaultShellRcPath,
runBrowserDoctor,
upsertJsonConfigToken,
upsertShellToken,
upsertTomlConfigToken,
} from './doctor.js';
import { getTokenFingerprint } from './browser.js';
import { type CheckboxItem, checkboxPrompt } from './tui.js';
const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
function fileExists(p: string): boolean {
try { return fs.statSync(p).isFile() || fs.statSync(p).isDirectory(); } catch { return false; }
}
function writeFileWithMkdir(filePath: string, content: string) {
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
if (dir && !fileExists(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, content, 'utf-8');
}
function shortenPath(p: string): string {
const home = process.env.HOME || process.env.USERPROFILE || '';
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
}
function toolName(p: string): string {
if (p.includes('.codex/')) return 'Codex';
if (p.includes('.cursor/')) return 'Cursor';
if (p.includes('.claude.json')) return 'Claude Code';
if (p.includes('antigravity')) return 'Antigravity';
if (p.includes('.gemini/settings')) return 'Gemini CLI';
if (p.includes('opencode')) return 'OpenCode';
if (p.includes('Claude/claude_desktop')) return 'Claude Desktop';
if (p.includes('.vscode/')) return 'VS Code';
if (p.includes('.mcp.json')) return 'Project MCP';
if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile')) return 'Shell';
return '';
}
export async function runSetup(opts: { cliVersion?: string; token?: string } = {}) {
console.log();
console.log(chalk.bold(' opencli setup') + chalk.dim(' — Playwright MCP token configuration'));
console.log();
// Step 1: Discover token
let token = opts.token ?? null;
if (!token) {
const extensionToken = discoverExtensionToken();
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
if (extensionToken && envToken && extensionToken === envToken) {
token = extensionToken;
console.log(` ${chalk.green('✓')} Token auto-discovered from Chrome extension`);
console.log(` Fingerprint: ${chalk.bold(getTokenFingerprint(token) ?? 'unknown')}`);
} else if (extensionToken) {
token = extensionToken;
console.log(` ${chalk.green('✓')} Token discovered from Chrome extension ` +
chalk.dim(`(${getTokenFingerprint(token)})`));
if (envToken && envToken !== extensionToken) {
console.log(` ${chalk.yellow('!')} Environment has different token ` +
chalk.dim(`(${getTokenFingerprint(envToken)})`));
}
} else if (envToken) {
token = envToken;
console.log(` ${chalk.green('✓')} Token from environment variable ` +
chalk.dim(`(${getTokenFingerprint(token)})`));
}
} else {
console.log(` ${chalk.green('✓')} Using provided token ` +
chalk.dim(`(${getTokenFingerprint(token)})`));
}
if (!token) {
console.log(` ${chalk.yellow('!')} No token found. Please enter it manually.`);
console.log(chalk.dim(' (Find it in the Playwright MCP Bridge extension → Status page)'));
console.log();
const rl = createInterface({ input, output });
const answer = await rl.question(' Token: ');
rl.close();
token = answer.trim();
if (!token) {
console.log(chalk.red('\n No token provided. Aborting.\n'));
return;
}
}
const fingerprint = getTokenFingerprint(token) ?? 'unknown';
console.log();
// Step 2: Scan all config locations
const report = await runBrowserDoctor({ token, cliVersion: opts.cliVersion });
// Step 3: Build checkbox items
const items: CheckboxItem[] = [];
// Shell file
const shellPath = report.shellFiles[0]?.path ?? getDefaultShellRcPath();
const shellStatus = report.shellFiles[0];
const shellFp = shellStatus?.fingerprint;
const shellOk = shellFp === fingerprint;
items.push({
label: padRight(`${shortenPath(shellPath)}`, 50) + chalk.dim(` [${toolName(shellPath) || 'Shell'}]`),
value: `shell:${shellPath}`,
checked: !shellOk,
status: shellOk ? `configured (${shellFp})` : shellFp ? `mismatch (${shellFp})` : 'missing',
statusColor: shellOk ? 'green' : shellFp ? 'yellow' : 'red',
});
// Config files
for (const config of report.configs) {
const fp = config.fingerprint;
const ok = fp === fingerprint;
const tool = toolName(config.path);
items.push({
label: padRight(`${shortenPath(config.path)}`, 50) + chalk.dim(tool ? ` [${tool}]` : ''),
value: `config:${config.path}`,
checked: !ok,
status: ok ? `configured (${fp})` : !config.exists ? 'will create' : fp ? `mismatch (${fp})` : 'missing',
statusColor: ok ? 'green' : 'yellow',
});
}
// Step 4: Show interactive checkbox
const selected = await checkboxPrompt(items, {
title: ` Select files to update with token ${chalk.cyan(fingerprint)}:`,
});
if (selected.length === 0) {
console.log(chalk.dim(' No changes made.\n'));
return;
}
// Step 5: Apply changes
const written: string[] = [];
for (const sel of selected) {
if (sel.startsWith('shell:')) {
const path = sel.slice('shell:'.length);
const before = fileExists(path) ? fs.readFileSync(path, 'utf-8') : '';
writeFileWithMkdir(path, upsertShellToken(before, token));
written.push(path);
} else if (sel.startsWith('config:')) {
const path = sel.slice('config:'.length);
const config = report.configs.find(c => c.path === path);
if (config && config.parseError) continue;
const before = fileExists(path) ? fs.readFileSync(path, 'utf-8') : '';
const format = config?.format ?? (path.endsWith('.toml') ? 'toml' : 'json');
const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
writeFileWithMkdir(path, next);
written.push(path);
}
}
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
// Step 6: Summary
if (written.length > 0) {
console.log(chalk.green.bold(` ✓ Updated ${written.length} file(s):`));
for (const p of written) {
console.log(` ${chalk.dim('•')} ${shortenPath(p)}`);
}
} else {
console.log(chalk.yellow(' No files were changed.'));
}
console.log();
}
function padRight(s: string, n: number): string {
// Account for ANSI escape codes in length calculation
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
return visible.length >= n ? s : s + ' '.repeat(n - visible.length);
}
+165
View File
@@ -0,0 +1,165 @@
/**
* tui.ts — Zero-dependency interactive TUI components
*
* Uses raw stdin mode + ANSI escape codes for interactive prompts.
*/
import chalk from 'chalk';
export interface CheckboxItem {
label: string;
value: string;
checked: boolean;
/** Optional status to display after the label */
status?: string;
statusColor?: 'green' | 'yellow' | 'red' | 'dim';
}
/**
* Interactive multi-select checkbox prompt.
*
* Controls:
* ↑/↓ or j/k — navigate
* Space — toggle selection
* a — toggle all
* Enter — confirm
* q/Esc — cancel (returns empty)
*/
export async function checkboxPrompt(
items: CheckboxItem[],
opts: { title?: string; hint?: string } = {},
): Promise<string[]> {
if (items.length === 0) return [];
const { stdin, stdout } = process;
if (!stdin.isTTY) {
// Non-interactive: return all checked items
return items.filter(i => i.checked).map(i => i.value);
}
let cursor = 0;
const state = items.map(i => ({ ...i }));
function colorStatus(status: string | undefined, color: CheckboxItem['statusColor']): string {
if (!status) return '';
switch (color) {
case 'green': return chalk.green(status);
case 'yellow': return chalk.yellow(status);
case 'red': return chalk.red(status);
case 'dim': return chalk.dim(status);
default: return chalk.dim(status);
}
}
function render() {
// Move cursor to start and clear
let out = '';
if (opts.title) {
out += `\n${chalk.bold(opts.title)}\n\n`;
}
for (let i = 0; i < state.length; i++) {
const item = state[i];
const pointer = i === cursor ? chalk.cyan('❯') : ' ';
const checkbox = item.checked ? chalk.green('◉') : chalk.dim('○');
const label = i === cursor ? chalk.bold(item.label) : item.label;
const status = colorStatus(item.status, item.statusColor);
out += ` ${pointer} ${checkbox} ${label}${status ? ` ${status}` : ''}\n`;
}
out += `\n ${chalk.dim('↑↓ navigate · Space toggle · a all · Enter confirm · q cancel')}\n`;
return out;
}
return new Promise<string[]>((resolve) => {
const wasRaw = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
let rendered = '';
function draw() {
// Clear previous render
if (rendered) {
const lines = rendered.split('\n').length;
stdout.write(`\x1b[${lines}A\x1b[J`);
}
rendered = render();
stdout.write(rendered);
}
function cleanup() {
stdin.setRawMode(wasRaw ?? false);
stdin.pause();
stdin.removeListener('data', onData);
// Clear the TUI
if (rendered) {
const lines = rendered.split('\n').length;
stdout.write(`\x1b[${lines}A\x1b[J`);
}
}
function onData(data: Buffer) {
const key = data.toString();
// Arrow up / k
if (key === '\x1b[A' || key === 'k') {
cursor = (cursor - 1 + state.length) % state.length;
draw();
return;
}
// Arrow down / j
if (key === '\x1b[B' || key === 'j') {
cursor = (cursor + 1) % state.length;
draw();
return;
}
// Space — toggle
if (key === ' ') {
state[cursor].checked = !state[cursor].checked;
draw();
return;
}
// Tab — toggle and move down
if (key === '\t') {
state[cursor].checked = !state[cursor].checked;
cursor = (cursor + 1) % state.length;
draw();
return;
}
// 'a' — toggle all
if (key === 'a') {
const allChecked = state.every(i => i.checked);
for (const item of state) item.checked = !allChecked;
draw();
return;
}
// Enter — confirm
if (key === '\r' || key === '\n') {
cleanup();
const selected = state.filter(i => i.checked).map(i => i.value);
// Show summary
stdout.write(` ${chalk.green('✓')} ${chalk.bold(`${selected.length} file(s) selected`)}\n\n`);
resolve(selected);
return;
}
// q / Esc / Ctrl+C — cancel
if (key === 'q' || key === '\x1b' || key === '\x03') {
cleanup();
stdout.write(` ${chalk.yellow('✗')} ${chalk.dim('Cancelled')}\n\n`);
resolve([]);
return;
}
}
stdin.on('data', onData);
draw();
});
}