@jackwener/opencli 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/README.zh-CN.md +4 -3
- package/SKILL.md +7 -4
- package/dist/browser.d.ts +7 -3
- package/dist/browser.js +25 -92
- package/dist/browser.test.js +18 -1
- package/dist/cascade.d.ts +1 -1
- package/dist/cascade.js +42 -75
- package/dist/cli-manifest.json +80 -0
- package/dist/clis/coupang/add-to-cart.d.ts +1 -0
- package/dist/clis/coupang/add-to-cart.js +141 -0
- package/dist/clis/coupang/search.d.ts +1 -0
- package/dist/clis/coupang/search.js +453 -0
- package/dist/constants.d.ts +13 -0
- package/dist/constants.js +30 -0
- package/dist/coupang.d.ts +24 -0
- package/dist/coupang.js +262 -0
- package/dist/coupang.test.d.ts +1 -0
- package/dist/coupang.test.js +62 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +226 -25
- package/dist/doctor.test.js +13 -6
- package/dist/engine.js +3 -3
- package/dist/engine.test.d.ts +4 -0
- package/dist/engine.test.js +67 -0
- package/dist/explore.js +1 -15
- package/dist/interceptor.d.ts +42 -0
- package/dist/interceptor.js +138 -0
- package/dist/main.js +8 -4
- package/dist/output.js +0 -5
- package/dist/pipeline/steps/intercept.js +4 -54
- package/dist/pipeline/steps/tap.js +11 -51
- package/dist/registry.d.ts +3 -1
- package/dist/registry.test.d.ts +4 -0
- package/dist/registry.test.js +90 -0
- package/dist/runtime.d.ts +15 -1
- package/dist/runtime.js +11 -6
- package/dist/setup.d.ts +4 -0
- package/dist/setup.js +145 -0
- package/dist/synthesize.js +5 -5
- package/dist/tui.d.ts +22 -0
- package/dist/tui.js +139 -0
- package/dist/validate.js +21 -0
- package/dist/verify.d.ts +7 -0
- package/dist/verify.js +7 -1
- package/dist/version.d.ts +4 -0
- package/dist/version.js +16 -0
- package/package.json +1 -1
- package/src/browser.test.ts +20 -1
- package/src/browser.ts +25 -87
- package/src/cascade.ts +47 -75
- package/src/clis/coupang/add-to-cart.ts +149 -0
- package/src/clis/coupang/search.ts +466 -0
- package/src/constants.ts +35 -0
- package/src/coupang.test.ts +78 -0
- package/src/coupang.ts +302 -0
- package/src/doctor.test.ts +15 -6
- package/src/doctor.ts +221 -25
- package/src/engine.test.ts +77 -0
- package/src/engine.ts +5 -5
- package/src/explore.ts +2 -15
- package/src/interceptor.ts +153 -0
- package/src/main.ts +9 -5
- package/src/output.ts +0 -4
- package/src/pipeline/executor.ts +15 -15
- package/src/pipeline/steps/intercept.ts +4 -55
- package/src/pipeline/steps/tap.ts +12 -51
- package/src/registry.test.ts +106 -0
- package/src/registry.ts +4 -1
- package/src/runtime.ts +22 -8
- package/src/setup.ts +169 -0
- package/src/synthesize.ts +5 -5
- package/src/tui.ts +171 -0
- package/src/validate.ts +22 -0
- package/src/verify.ts +10 -1
- package/src/version.ts +18 -0
package/src/doctor.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
4
5
|
import { createInterface } from 'node:readline/promises';
|
|
5
6
|
import { stdin as input, stdout as output } from 'node:process';
|
|
7
|
+
import chalk from 'chalk';
|
|
6
8
|
import type { IPage } from './types.js';
|
|
7
9
|
import { PlaywrightMCP, getTokenFingerprint } from './browser.js';
|
|
8
10
|
import { browserSession } from './runtime.js';
|
|
9
11
|
|
|
10
12
|
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
|
11
|
-
const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
|
|
13
|
+
export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
|
|
12
14
|
const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
|
|
13
15
|
const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
|
|
14
16
|
export type DoctorOptions = {
|
|
@@ -43,6 +45,8 @@ export type DoctorReport = {
|
|
|
43
45
|
cliVersion?: string;
|
|
44
46
|
envToken: string | null;
|
|
45
47
|
envFingerprint: string | null;
|
|
48
|
+
extensionToken: string | null;
|
|
49
|
+
extensionFingerprint: string | null;
|
|
46
50
|
shellFiles: ShellFileStatus[];
|
|
47
51
|
configs: McpConfigStatus[];
|
|
48
52
|
recommendedToken: string | null;
|
|
@@ -53,17 +57,41 @@ export type DoctorReport = {
|
|
|
53
57
|
|
|
54
58
|
type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN';
|
|
55
59
|
|
|
56
|
-
function
|
|
57
|
-
|
|
60
|
+
function colorLabel(status: ReportStatus): string {
|
|
61
|
+
switch (status) {
|
|
62
|
+
case 'OK': return chalk.green('[OK]');
|
|
63
|
+
case 'MISSING': return chalk.red('[MISSING]');
|
|
64
|
+
case 'MISMATCH': return chalk.yellow('[MISMATCH]');
|
|
65
|
+
case 'WARN': return chalk.yellow('[WARN]');
|
|
66
|
+
}
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
function statusLine(status: ReportStatus, text: string): string {
|
|
61
|
-
return `${
|
|
70
|
+
return `${colorLabel(status)} ${text}`;
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
function tokenSummary(token: string | null, fingerprint: string | null): string {
|
|
65
|
-
if (!token) return 'missing';
|
|
66
|
-
return `configured (${fingerprint})`;
|
|
74
|
+
if (!token) return chalk.dim('missing');
|
|
75
|
+
return `configured ${chalk.dim(`(${fingerprint})`)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function shortenPath(p: string): string {
|
|
79
|
+
const home = os.homedir();
|
|
80
|
+
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function toolName(p: string): string {
|
|
84
|
+
if (p.includes('.codex/')) return 'Codex';
|
|
85
|
+
if (p.includes('.cursor/')) return 'Cursor';
|
|
86
|
+
if (p.includes('.claude.json')) return 'Claude Code';
|
|
87
|
+
if (p.includes('antigravity')) return 'Antigravity';
|
|
88
|
+
if (p.includes('.gemini/settings')) return 'Gemini CLI';
|
|
89
|
+
if (p.includes('opencode')) return 'OpenCode';
|
|
90
|
+
if (p.includes('Claude/claude_desktop')) return 'Claude Desktop';
|
|
91
|
+
if (p.includes('.vscode/')) return 'VS Code';
|
|
92
|
+
if (p.includes('.mcp.json')) return 'Project MCP';
|
|
93
|
+
if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile')) return 'Shell';
|
|
94
|
+
return '';
|
|
67
95
|
}
|
|
68
96
|
|
|
69
97
|
export function getDefaultShellRcPath(): string {
|
|
@@ -79,12 +107,16 @@ export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[]
|
|
|
79
107
|
path.join(home, '.codex', 'config.toml'),
|
|
80
108
|
path.join(home, '.codex', 'mcp.json'),
|
|
81
109
|
path.join(home, '.cursor', 'mcp.json'),
|
|
110
|
+
path.join(home, '.claude.json'),
|
|
111
|
+
path.join(home, '.gemini', 'settings.json'),
|
|
112
|
+
path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
|
|
82
113
|
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
83
114
|
path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
84
115
|
path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
|
|
85
116
|
path.join(cwd, '.cursor', 'mcp.json'),
|
|
86
117
|
path.join(cwd, '.vscode', 'mcp.json'),
|
|
87
118
|
path.join(cwd, '.opencode', 'opencode.json'),
|
|
119
|
+
path.join(cwd, '.mcp.json'),
|
|
88
120
|
];
|
|
89
121
|
return [...new Set(candidates)];
|
|
90
122
|
}
|
|
@@ -170,7 +202,7 @@ export function upsertTomlConfigToken(content: string, token: string): string {
|
|
|
170
202
|
return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
|
|
171
203
|
}
|
|
172
204
|
|
|
173
|
-
function fileExists(filePath: string): boolean {
|
|
205
|
+
export function fileExists(filePath: string): boolean {
|
|
174
206
|
try {
|
|
175
207
|
return fs.existsSync(filePath);
|
|
176
208
|
} catch {
|
|
@@ -220,6 +252,145 @@ function readConfigStatus(filePath: string): McpConfigStatus {
|
|
|
220
252
|
}
|
|
221
253
|
}
|
|
222
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Discover the auth token stored by the Playwright MCP Bridge extension
|
|
257
|
+
* by scanning Chrome's LevelDB localStorage files directly.
|
|
258
|
+
*
|
|
259
|
+
* Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
|
|
260
|
+
* with a pure-Node fallback on Windows.
|
|
261
|
+
*/
|
|
262
|
+
export function discoverExtensionToken(): string | null {
|
|
263
|
+
const home = os.homedir();
|
|
264
|
+
const platform = os.platform();
|
|
265
|
+
const bases: string[] = [];
|
|
266
|
+
|
|
267
|
+
if (platform === 'darwin') {
|
|
268
|
+
bases.push(
|
|
269
|
+
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
|
|
270
|
+
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
|
|
271
|
+
path.join(home, 'Library', 'Application Support', 'Chromium'),
|
|
272
|
+
path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
|
|
273
|
+
);
|
|
274
|
+
} else if (platform === 'linux') {
|
|
275
|
+
bases.push(
|
|
276
|
+
path.join(home, '.config', 'google-chrome'),
|
|
277
|
+
path.join(home, '.config', 'chromium'),
|
|
278
|
+
path.join(home, '.config', 'microsoft-edge'),
|
|
279
|
+
);
|
|
280
|
+
} else if (platform === 'win32') {
|
|
281
|
+
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
282
|
+
bases.push(
|
|
283
|
+
path.join(appData, 'Google', 'Chrome', 'User Data'),
|
|
284
|
+
path.join(appData, 'Microsoft', 'Edge', 'User Data'),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
|
289
|
+
// Token is 43 chars of base64url (from 32 random bytes)
|
|
290
|
+
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
|
291
|
+
|
|
292
|
+
for (const base of bases) {
|
|
293
|
+
for (const profile of profiles) {
|
|
294
|
+
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
|
295
|
+
if (!fileExists(dir)) continue;
|
|
296
|
+
|
|
297
|
+
// Fast path: use strings + grep to find candidate files and extract token
|
|
298
|
+
if (platform !== 'win32') {
|
|
299
|
+
const token = extractTokenViaStrings(dir, tokenRe);
|
|
300
|
+
if (token) return token;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Slow path (Windows): read binary files directly
|
|
305
|
+
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
|
306
|
+
if (token) return token;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function extractTokenViaStrings(dir: string, tokenRe: RegExp): string | null {
|
|
314
|
+
try {
|
|
315
|
+
// Single shell pipeline: for each LevelDB file, extract strings, find lines
|
|
316
|
+
// after the extension ID, and filter for base64url token pattern.
|
|
317
|
+
//
|
|
318
|
+
// LevelDB `strings` output for the extension's auth-token entry:
|
|
319
|
+
// auth-token ← key name
|
|
320
|
+
// 4,mmlmfjhmonkocbjadbfplnigmagldckm.7 ← LevelDB internal key
|
|
321
|
+
// hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA ← token value
|
|
322
|
+
//
|
|
323
|
+
// We get the line immediately after any EXTENSION_ID mention and check
|
|
324
|
+
// if it looks like a base64url token (40-50 chars, [A-Za-z0-9_-]).
|
|
325
|
+
const shellDir = dir.replace(/'/g, "'\\''");
|
|
326
|
+
const cmd = `for f in '${shellDir}'/*.ldb '${shellDir}'/*.log; do ` +
|
|
327
|
+
`[ -f "$f" ] && strings "$f" 2>/dev/null | ` +
|
|
328
|
+
`grep -A1 '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
|
329
|
+
`grep -v '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
|
330
|
+
`grep -E '^[A-Za-z0-9_-]{40,50}$' | head -1; ` +
|
|
331
|
+
`done 2>/dev/null`;
|
|
332
|
+
const result = execSync(cmd, { encoding: 'utf-8', timeout: 10000 }).trim();
|
|
333
|
+
|
|
334
|
+
// Take the first non-empty line
|
|
335
|
+
for (const line of result.split('\n')) {
|
|
336
|
+
const token = line.trim();
|
|
337
|
+
if (token && validateBase64urlToken(token)) return token;
|
|
338
|
+
}
|
|
339
|
+
} catch {}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
|
|
344
|
+
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
|
345
|
+
const keyBuf = Buffer.from('auth-token');
|
|
346
|
+
|
|
347
|
+
let files: string[];
|
|
348
|
+
try {
|
|
349
|
+
files = fs.readdirSync(dir)
|
|
350
|
+
.filter(f => f.endsWith('.ldb') || f.endsWith('.log'))
|
|
351
|
+
.map(f => path.join(dir, f));
|
|
352
|
+
} catch { return null; }
|
|
353
|
+
|
|
354
|
+
// Sort by mtime descending
|
|
355
|
+
files.sort((a, b) => {
|
|
356
|
+
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
for (const file of files) {
|
|
360
|
+
let data: Buffer;
|
|
361
|
+
try { data = fs.readFileSync(file); } catch { continue; }
|
|
362
|
+
|
|
363
|
+
// Quick check: does file contain both the extension ID and auth-token key?
|
|
364
|
+
const extPos = data.indexOf(extIdBuf);
|
|
365
|
+
if (extPos === -1) continue;
|
|
366
|
+
const keyPos = data.indexOf(keyBuf, Math.max(0, extPos - 500));
|
|
367
|
+
if (keyPos === -1) continue;
|
|
368
|
+
|
|
369
|
+
// Scan for token value after auth-token key
|
|
370
|
+
let idx = 0;
|
|
371
|
+
while (true) {
|
|
372
|
+
const kp = data.indexOf(keyBuf, idx);
|
|
373
|
+
if (kp === -1) break;
|
|
374
|
+
|
|
375
|
+
const contextStart = Math.max(0, kp - 500);
|
|
376
|
+
if (data.indexOf(extIdBuf, contextStart) !== -1 && data.indexOf(extIdBuf, contextStart) < kp) {
|
|
377
|
+
const after = data.subarray(kp + keyBuf.length, kp + keyBuf.length + 200).toString('latin1');
|
|
378
|
+
const m = after.match(tokenRe);
|
|
379
|
+
if (m && validateBase64urlToken(m[1])) return m[1];
|
|
380
|
+
}
|
|
381
|
+
idx = kp + 1;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function validateBase64urlToken(token: string): boolean {
|
|
388
|
+
try {
|
|
389
|
+
const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
|
|
390
|
+
const decoded = Buffer.from(b64, 'base64');
|
|
391
|
+
return decoded.length >= 28 && decoded.length <= 36;
|
|
392
|
+
} catch { return false; }
|
|
393
|
+
}
|
|
223
394
|
|
|
224
395
|
|
|
225
396
|
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
|
@@ -234,19 +405,25 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|
|
234
405
|
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
|
|
235
406
|
const configs = configPaths.map(readConfigStatus);
|
|
236
407
|
|
|
408
|
+
// Try to discover the token directly from the Chrome extension's localStorage
|
|
409
|
+
const extensionToken = discoverExtensionToken();
|
|
410
|
+
|
|
237
411
|
const allTokens = [
|
|
238
412
|
opts.token ?? null,
|
|
413
|
+
extensionToken,
|
|
239
414
|
envToken,
|
|
240
415
|
...shellFiles.map(s => s.token),
|
|
241
416
|
...configs.map(c => c.token),
|
|
242
417
|
].filter((v): v is string => !!v);
|
|
243
418
|
const uniqueTokens = [...new Set(allTokens)];
|
|
244
|
-
const recommendedToken = opts.token ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
|
419
|
+
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
|
245
420
|
|
|
246
421
|
const report: DoctorReport = {
|
|
247
422
|
cliVersion: opts.cliVersion,
|
|
248
423
|
envToken,
|
|
249
424
|
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
|
425
|
+
extensionToken,
|
|
426
|
+
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
|
250
427
|
shellFiles,
|
|
251
428
|
configs,
|
|
252
429
|
recommendedToken,
|
|
@@ -270,26 +447,32 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|
|
270
447
|
|
|
271
448
|
export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
272
449
|
const tokenFingerprints = [
|
|
450
|
+
report.extensionFingerprint,
|
|
273
451
|
report.envFingerprint,
|
|
274
452
|
...report.shellFiles.map(shell => shell.fingerprint),
|
|
275
453
|
...report.configs.filter(config => config.exists).map(config => config.fingerprint),
|
|
276
454
|
].filter((value): value is string => !!value);
|
|
277
455
|
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
|
278
456
|
const hasMismatch = uniqueFingerprints.length > 1;
|
|
279
|
-
const lines = [`opencli v${report.cliVersion ?? 'unknown'} doctor
|
|
457
|
+
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
458
|
+
|
|
459
|
+
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
460
|
+
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
|
280
461
|
|
|
281
462
|
const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
282
463
|
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
|
|
283
464
|
|
|
284
465
|
for (const shell of report.shellFiles) {
|
|
285
466
|
const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
286
|
-
|
|
467
|
+
const tool = toolName(shell.path);
|
|
468
|
+
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
|
|
469
|
+
lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
|
|
287
470
|
}
|
|
288
471
|
const existingConfigs = report.configs.filter(config => config.exists);
|
|
289
472
|
const missingConfigCount = report.configs.length - existingConfigs.length;
|
|
290
473
|
if (existingConfigs.length > 0) {
|
|
291
474
|
for (const config of existingConfigs) {
|
|
292
|
-
const parseSuffix = config.parseError ? ` (parse error
|
|
475
|
+
const parseSuffix = config.parseError ? chalk.red(` (parse error)`) : '';
|
|
293
476
|
const configStatus: ReportStatus = config.parseError
|
|
294
477
|
? 'WARN'
|
|
295
478
|
: !config.token
|
|
@@ -297,24 +480,26 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
|
297
480
|
: hasMismatch
|
|
298
481
|
? 'MISMATCH'
|
|
299
482
|
: 'OK';
|
|
300
|
-
|
|
483
|
+
const tool = toolName(config.path);
|
|
484
|
+
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
|
|
485
|
+
lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
|
|
301
486
|
}
|
|
302
487
|
} else {
|
|
303
|
-
lines.push(statusLine('MISSING', 'MCP config: no existing config files found
|
|
488
|
+
lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
|
|
304
489
|
}
|
|
305
|
-
if (missingConfigCount > 0) lines.push(` Other scanned config locations not present: ${missingConfigCount}`);
|
|
490
|
+
if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
|
306
491
|
lines.push('');
|
|
307
492
|
lines.push(statusLine(
|
|
308
493
|
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
|
|
309
494
|
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
|
|
310
495
|
));
|
|
311
496
|
if (report.issues.length) {
|
|
312
|
-
lines.push('', 'Issues:');
|
|
313
|
-
for (const issue of report.issues) lines.push(
|
|
497
|
+
lines.push('', chalk.yellow('Issues:'));
|
|
498
|
+
for (const issue of report.issues) lines.push(chalk.dim(` • ${issue}`));
|
|
314
499
|
}
|
|
315
500
|
if (report.warnings.length) {
|
|
316
|
-
lines.push('', 'Warnings:');
|
|
317
|
-
for (const warning of report.warnings) lines.push(
|
|
501
|
+
lines.push('', chalk.yellow('Warnings:'));
|
|
502
|
+
for (const warning of report.warnings) lines.push(chalk.dim(` • ${warning}`));
|
|
318
503
|
}
|
|
319
504
|
return lines.join('\n');
|
|
320
505
|
}
|
|
@@ -329,7 +514,7 @@ async function confirmPrompt(question: string): Promise<boolean> {
|
|
|
329
514
|
}
|
|
330
515
|
}
|
|
331
516
|
|
|
332
|
-
function writeFileWithMkdir(filePath: string, content: string): void {
|
|
517
|
+
export function writeFileWithMkdir(filePath: string, content: string): void {
|
|
333
518
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
334
519
|
fs.writeFileSync(filePath, content, 'utf-8');
|
|
335
520
|
}
|
|
@@ -337,27 +522,38 @@ function writeFileWithMkdir(filePath: string, content: string): void {
|
|
|
337
522
|
export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
|
|
338
523
|
const token = opts.token ?? report.recommendedToken;
|
|
339
524
|
if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token first.');
|
|
525
|
+
const fp = getTokenFingerprint(token);
|
|
340
526
|
|
|
341
527
|
const plannedWrites: string[] = [];
|
|
342
528
|
const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
|
|
343
|
-
|
|
529
|
+
const shellStatus = report.shellFiles.find(s => s.path === shellPath);
|
|
530
|
+
if (shellStatus?.fingerprint !== fp) plannedWrites.push(shellPath);
|
|
344
531
|
for (const config of report.configs) {
|
|
345
532
|
if (!config.writable) continue;
|
|
533
|
+
if (config.fingerprint === fp) continue; // already correct
|
|
346
534
|
plannedWrites.push(config.path);
|
|
347
535
|
}
|
|
348
536
|
|
|
537
|
+
if (plannedWrites.length === 0) {
|
|
538
|
+
console.log(chalk.green('All config files are already up to date.'));
|
|
539
|
+
return [];
|
|
540
|
+
}
|
|
541
|
+
|
|
349
542
|
if (!opts.yes) {
|
|
350
|
-
const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${
|
|
543
|
+
const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
|
|
351
544
|
if (!ok) return [];
|
|
352
545
|
}
|
|
353
546
|
|
|
354
547
|
const written: string[] = [];
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
548
|
+
if (plannedWrites.includes(shellPath)) {
|
|
549
|
+
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
|
|
550
|
+
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
|
|
551
|
+
written.push(shellPath);
|
|
552
|
+
}
|
|
358
553
|
|
|
359
554
|
for (const config of report.configs) {
|
|
360
|
-
if (!
|
|
555
|
+
if (!plannedWrites.includes(config.path)) continue;
|
|
556
|
+
if (config.parseError) continue;
|
|
361
557
|
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
|
|
362
558
|
const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
|
|
363
559
|
writeFileWithMkdir(config.path, next);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for engine.ts: CLI discovery and command execution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import { discoverClis, executeCommand } from './engine.js';
|
|
7
|
+
import { getRegistry, cli, Strategy } from './registry.js';
|
|
8
|
+
|
|
9
|
+
describe('discoverClis', () => {
|
|
10
|
+
it('handles non-existent directories gracefully', async () => {
|
|
11
|
+
// Should not throw for missing directories
|
|
12
|
+
await expect(discoverClis('/tmp/nonexistent-opencli-test-dir')).resolves.not.toThrow();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('executeCommand', () => {
|
|
17
|
+
it('executes a command with func', async () => {
|
|
18
|
+
const cmd = cli({
|
|
19
|
+
site: 'test-engine',
|
|
20
|
+
name: 'func-test',
|
|
21
|
+
description: 'test command with func',
|
|
22
|
+
browser: false,
|
|
23
|
+
strategy: Strategy.PUBLIC,
|
|
24
|
+
func: async (_page, kwargs) => {
|
|
25
|
+
return [{ title: kwargs.query ?? 'default' }];
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = await executeCommand(cmd, null, { query: 'hello' });
|
|
30
|
+
expect(result).toEqual([{ title: 'hello' }]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('executes a command with pipeline', async () => {
|
|
34
|
+
const cmd = cli({
|
|
35
|
+
site: 'test-engine',
|
|
36
|
+
name: 'pipe-test',
|
|
37
|
+
description: 'test command with pipeline',
|
|
38
|
+
browser: false,
|
|
39
|
+
strategy: Strategy.PUBLIC,
|
|
40
|
+
pipeline: [
|
|
41
|
+
{ evaluate: '() => [{ n: 1 }, { n: 2 }, { n: 3 }]' },
|
|
42
|
+
{ limit: '2' },
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Pipeline commands require page for evaluate step, so we'll test the error path
|
|
47
|
+
await expect(executeCommand(cmd, null, {})).rejects.toThrow();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('throws for command with no func or pipeline', async () => {
|
|
51
|
+
const cmd = cli({
|
|
52
|
+
site: 'test-engine',
|
|
53
|
+
name: 'empty-test',
|
|
54
|
+
description: 'empty command',
|
|
55
|
+
browser: false,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await expect(executeCommand(cmd, null, {})).rejects.toThrow('has no func or pipeline');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('passes debug flag to func', async () => {
|
|
62
|
+
let receivedDebug = false;
|
|
63
|
+
const cmd = cli({
|
|
64
|
+
site: 'test-engine',
|
|
65
|
+
name: 'debug-test',
|
|
66
|
+
description: 'debug test',
|
|
67
|
+
browser: false,
|
|
68
|
+
func: async (_page, _kwargs, debug) => {
|
|
69
|
+
receivedDebug = debug ?? false;
|
|
70
|
+
return [];
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await executeCommand(cmd, null, {}, true);
|
|
75
|
+
expect(receivedDebug).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/engine.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import * as fs from 'node:fs';
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
import yaml from 'js-yaml';
|
|
14
|
-
import { type CliCommand, type Arg, Strategy, registerCommand } from './registry.js';
|
|
14
|
+
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
|
|
15
15
|
import type { IPage } from './types.js';
|
|
16
16
|
import { executePipeline } from './pipeline.js';
|
|
17
17
|
|
|
@@ -66,7 +66,7 @@ function loadFromManifest(manifestPath: string, clisDir: string): void {
|
|
|
66
66
|
// The actual module is loaded lazily on first executeCommand().
|
|
67
67
|
const strategy = (Strategy as any)[(entry.strategy ?? 'cookie').toUpperCase()] ?? Strategy.COOKIE;
|
|
68
68
|
const modulePath = path.resolve(clisDir, entry.modulePath);
|
|
69
|
-
const cmd:
|
|
69
|
+
const cmd: InternalCliCommand = {
|
|
70
70
|
site: entry.site,
|
|
71
71
|
name: entry.name,
|
|
72
72
|
description: entry.description ?? '',
|
|
@@ -77,7 +77,6 @@ function loadFromManifest(manifestPath: string, clisDir: string): void {
|
|
|
77
77
|
columns: entry.columns,
|
|
78
78
|
timeoutSeconds: entry.timeout,
|
|
79
79
|
source: modulePath,
|
|
80
|
-
// Mark as lazy — executeCommand will load the module before running
|
|
81
80
|
_lazy: true,
|
|
82
81
|
_modulePath: modulePath,
|
|
83
82
|
};
|
|
@@ -170,8 +169,9 @@ export async function executeCommand(
|
|
|
170
169
|
debug: boolean = false,
|
|
171
170
|
): Promise<any> {
|
|
172
171
|
// Lazy-load TS module on first execution
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
const internal = cmd as InternalCliCommand;
|
|
173
|
+
if (internal._lazy && internal._modulePath) {
|
|
174
|
+
const modulePath = internal._modulePath;
|
|
175
175
|
if (!_loadedModules.has(modulePath)) {
|
|
176
176
|
try {
|
|
177
177
|
await import(`file://${modulePath}`);
|
package/src/explore.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as fs from 'node:fs';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { DEFAULT_BROWSER_EXPLORE_TIMEOUT, browserSession, runWithTimeout } from './runtime.js';
|
|
12
|
+
import { VOLATILE_PARAMS, SEARCH_PARAMS, PAGINATION_PARAMS, LIMIT_PARAMS, FIELD_ROLES } from './constants.js';
|
|
12
13
|
|
|
13
14
|
// ── Site name detection ────────────────────────────────────────────────────
|
|
14
15
|
|
|
@@ -43,21 +44,7 @@ export function slugify(value: string): string {
|
|
|
43
44
|
|
|
44
45
|
// ── Field & capability inference ───────────────────────────────────────────
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
title: ['title', 'name', 'text', 'content', 'desc', 'description', 'headline', 'subject'],
|
|
48
|
-
url: ['url', 'uri', 'link', 'href', 'permalink', 'jump_url', 'web_url', 'share_url'],
|
|
49
|
-
author: ['author', 'username', 'user_name', 'nickname', 'nick', 'owner', 'creator', 'up_name', 'uname'],
|
|
50
|
-
score: ['score', 'hot', 'heat', 'likes', 'like_count', 'view_count', 'views', 'play', 'favorite_count', 'reply_count'],
|
|
51
|
-
time: ['time', 'created_at', 'publish_time', 'pub_time', 'date', 'ctime', 'mtime', 'pubdate', 'created'],
|
|
52
|
-
id: ['id', 'aid', 'bvid', 'mid', 'uid', 'oid', 'note_id', 'item_id'],
|
|
53
|
-
cover: ['cover', 'pic', 'image', 'thumbnail', 'poster', 'avatar'],
|
|
54
|
-
category: ['category', 'tag', 'type', 'tname', 'channel', 'section'],
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const SEARCH_PARAMS = new Set(['q', 'query', 'keyword', 'search', 'wd', 'kw', 'search_query', 'w']);
|
|
58
|
-
const PAGINATION_PARAMS = new Set(['page', 'pn', 'offset', 'cursor', 'next', 'page_num']);
|
|
59
|
-
const LIMIT_PARAMS = new Set(['limit', 'count', 'size', 'per_page', 'page_size', 'ps', 'num']);
|
|
60
|
-
const VOLATILE_PARAMS = new Set(['w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign']);
|
|
47
|
+
// (constants now imported from constants.ts)
|
|
61
48
|
|
|
62
49
|
// ── Network analysis ───────────────────────────────────────────────────────
|
|
63
50
|
|