@jackwener/opencli 0.4.1 → 0.4.3
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/CLI-CREATOR.md +103 -142
- package/LICENSE +28 -0
- package/README.md +113 -63
- package/README.zh-CN.md +114 -63
- package/SKILL.md +21 -4
- package/dist/browser.d.ts +21 -2
- package/dist/browser.js +269 -15
- package/dist/browser.test.d.ts +1 -0
- package/dist/browser.test.js +43 -0
- package/dist/build-manifest.js +66 -2
- package/dist/cli-manifest.json +905 -109
- package/dist/clis/boss/search.js +186 -30
- package/dist/clis/twitter/delete.d.ts +1 -0
- package/dist/clis/twitter/delete.js +73 -0
- package/dist/clis/twitter/followers.d.ts +1 -0
- package/dist/clis/twitter/followers.js +104 -0
- package/dist/clis/twitter/following.d.ts +1 -0
- package/dist/clis/twitter/following.js +90 -0
- package/dist/clis/twitter/like.d.ts +1 -0
- package/dist/clis/twitter/like.js +69 -0
- package/dist/clis/twitter/notifications.d.ts +1 -0
- package/dist/clis/twitter/notifications.js +109 -0
- package/dist/clis/twitter/post.d.ts +1 -0
- package/dist/clis/twitter/post.js +63 -0
- package/dist/clis/twitter/reply.d.ts +1 -0
- package/dist/clis/twitter/reply.js +57 -0
- package/dist/clis/v2ex/daily.d.ts +1 -0
- package/dist/clis/v2ex/daily.js +98 -0
- package/dist/clis/v2ex/me.d.ts +1 -0
- package/dist/clis/v2ex/me.js +99 -0
- package/dist/clis/v2ex/notifications.d.ts +1 -0
- package/dist/clis/v2ex/notifications.js +72 -0
- package/dist/clis/xiaohongshu/search.d.ts +5 -2
- package/dist/clis/xiaohongshu/search.js +35 -41
- package/dist/doctor.d.ts +50 -0
- package/dist/doctor.js +372 -0
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +114 -0
- package/dist/main.js +47 -5
- package/dist/output.test.d.ts +1 -0
- package/dist/output.test.js +20 -0
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +1 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +2 -2
- package/package.json +2 -2
- package/src/browser.test.ts +51 -0
- package/src/browser.ts +318 -22
- package/src/build-manifest.ts +67 -2
- package/src/clis/boss/search.ts +196 -29
- package/src/clis/twitter/delete.ts +78 -0
- package/src/clis/twitter/followers.ts +119 -0
- package/src/clis/twitter/following.ts +105 -0
- package/src/clis/twitter/like.ts +74 -0
- package/src/clis/twitter/notifications.ts +119 -0
- package/src/clis/twitter/post.ts +68 -0
- package/src/clis/twitter/reply.ts +62 -0
- package/src/clis/v2ex/daily.ts +105 -0
- package/src/clis/v2ex/me.ts +103 -0
- package/src/clis/v2ex/notifications.ts +77 -0
- package/src/clis/xiaohongshu/search.ts +41 -44
- package/src/doctor.test.ts +133 -0
- package/src/doctor.ts +424 -0
- package/src/main.ts +47 -4
- package/src/output.test.ts +27 -0
- package/src/registry.ts +5 -0
- package/src/runtime.ts +2 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
readTokenFromShellContent,
|
|
4
|
+
renderBrowserDoctorReport,
|
|
5
|
+
upsertShellToken,
|
|
6
|
+
readTomlConfigToken,
|
|
7
|
+
upsertTomlConfigToken,
|
|
8
|
+
upsertJsonConfigToken,
|
|
9
|
+
} from './doctor.js';
|
|
10
|
+
|
|
11
|
+
describe('shell token helpers', () => {
|
|
12
|
+
it('reads token from shell export', () => {
|
|
13
|
+
expect(readTokenFromShellContent('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"\n')).toBe('abc123');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('appends token export when missing', () => {
|
|
17
|
+
const next = upsertShellToken('export PATH="/usr/bin"\n', 'abc123');
|
|
18
|
+
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('replaces token export when present', () => {
|
|
22
|
+
const next = upsertShellToken('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="old"\n', 'new');
|
|
23
|
+
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="new"');
|
|
24
|
+
expect(next).not.toContain('"old"');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('toml token helpers', () => {
|
|
29
|
+
it('reads token from playwright env section', () => {
|
|
30
|
+
const content = `
|
|
31
|
+
[mcp_servers.playwright.env]
|
|
32
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"
|
|
33
|
+
`;
|
|
34
|
+
expect(readTomlConfigToken(content)).toBe('abc123');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('updates token inside existing env section', () => {
|
|
38
|
+
const content = `
|
|
39
|
+
[mcp_servers.playwright.env]
|
|
40
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "old"
|
|
41
|
+
`;
|
|
42
|
+
const next = upsertTomlConfigToken(content, 'new');
|
|
43
|
+
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "new"');
|
|
44
|
+
expect(next).not.toContain('"old"');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('creates env section when missing', () => {
|
|
48
|
+
const content = `
|
|
49
|
+
[mcp_servers.playwright]
|
|
50
|
+
type = "stdio"
|
|
51
|
+
`;
|
|
52
|
+
const next = upsertTomlConfigToken(content, 'abc123');
|
|
53
|
+
expect(next).toContain('[mcp_servers.playwright.env]');
|
|
54
|
+
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('json token helpers', () => {
|
|
59
|
+
it('writes token into standard mcpServers config', () => {
|
|
60
|
+
const next = upsertJsonConfigToken(JSON.stringify({
|
|
61
|
+
mcpServers: {
|
|
62
|
+
playwright: {
|
|
63
|
+
command: 'npx',
|
|
64
|
+
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}), 'abc123');
|
|
68
|
+
const parsed = JSON.parse(next);
|
|
69
|
+
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('writes token into opencode mcp config', () => {
|
|
73
|
+
const next = upsertJsonConfigToken(JSON.stringify({
|
|
74
|
+
$schema: 'https://opencode.ai/config.json',
|
|
75
|
+
mcp: {
|
|
76
|
+
playwright: {
|
|
77
|
+
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
|
78
|
+
enabled: true,
|
|
79
|
+
type: 'local',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
}), 'abc123');
|
|
83
|
+
const parsed = JSON.parse(next);
|
|
84
|
+
expect(parsed.mcp.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('doctor report rendering', () => {
|
|
89
|
+
it('renders OK-style report when tokens match', () => {
|
|
90
|
+
const text = renderBrowserDoctorReport({
|
|
91
|
+
envToken: 'abc123',
|
|
92
|
+
envFingerprint: 'fp1',
|
|
93
|
+
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
|
94
|
+
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
95
|
+
remoteDebuggingEnabled: true,
|
|
96
|
+
remoteDebuggingEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test',
|
|
97
|
+
cdpEnabled: false,
|
|
98
|
+
cdpToken: null,
|
|
99
|
+
cdpFingerprint: null,
|
|
100
|
+
recommendedToken: 'abc123',
|
|
101
|
+
recommendedFingerprint: 'fp1',
|
|
102
|
+
warnings: [],
|
|
103
|
+
issues: [],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(text).toContain('[OK] Chrome remote debugging: enabled');
|
|
107
|
+
expect(text).toContain('[OK] Environment token: configured (fp1)');
|
|
108
|
+
expect(text).toContain('[OK] MCP config /tmp/mcp.json: configured (fp1)');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('renders MISMATCH-style report when fingerprints differ', () => {
|
|
112
|
+
const text = renderBrowserDoctorReport({
|
|
113
|
+
envToken: 'abc123',
|
|
114
|
+
envFingerprint: 'fp1',
|
|
115
|
+
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
|
116
|
+
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
117
|
+
remoteDebuggingEnabled: false,
|
|
118
|
+
remoteDebuggingEndpoint: null,
|
|
119
|
+
cdpEnabled: false,
|
|
120
|
+
cdpToken: null,
|
|
121
|
+
cdpFingerprint: null,
|
|
122
|
+
recommendedToken: 'abc123',
|
|
123
|
+
recommendedFingerprint: 'fp1',
|
|
124
|
+
warnings: ['Chrome remote debugging appears to be disabled or Chrome is not currently exposing a DevTools endpoint.'],
|
|
125
|
+
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(text).toContain('[WARN] Chrome remote debugging: disabled');
|
|
129
|
+
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
|
|
130
|
+
expect(text).toContain('[MISMATCH] Shell file /tmp/.zshrc: configured (fp2)');
|
|
131
|
+
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
6
|
+
import type { IPage } from './types.js';
|
|
7
|
+
import { PlaywrightMCP, discoverChromeEndpoint, getTokenFingerprint } from './browser.js';
|
|
8
|
+
import { browserSession } from './runtime.js';
|
|
9
|
+
|
|
10
|
+
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
|
11
|
+
const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
|
|
12
|
+
const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
|
|
13
|
+
const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
|
|
14
|
+
export type DoctorOptions = {
|
|
15
|
+
fix?: boolean;
|
|
16
|
+
yes?: boolean;
|
|
17
|
+
shellRc?: string;
|
|
18
|
+
configPaths?: string[];
|
|
19
|
+
token?: string;
|
|
20
|
+
cliVersion?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ShellFileStatus = {
|
|
24
|
+
path: string;
|
|
25
|
+
exists: boolean;
|
|
26
|
+
token: string | null;
|
|
27
|
+
fingerprint: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type McpConfigFormat = 'json' | 'toml';
|
|
31
|
+
|
|
32
|
+
export type McpConfigStatus = {
|
|
33
|
+
path: string;
|
|
34
|
+
exists: boolean;
|
|
35
|
+
format: McpConfigFormat;
|
|
36
|
+
token: string | null;
|
|
37
|
+
fingerprint: string | null;
|
|
38
|
+
writable: boolean;
|
|
39
|
+
parseError?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type DoctorReport = {
|
|
43
|
+
cliVersion?: string;
|
|
44
|
+
envToken: string | null;
|
|
45
|
+
envFingerprint: string | null;
|
|
46
|
+
shellFiles: ShellFileStatus[];
|
|
47
|
+
configs: McpConfigStatus[];
|
|
48
|
+
remoteDebuggingEnabled: boolean;
|
|
49
|
+
remoteDebuggingEndpoint: string | null;
|
|
50
|
+
cdpEnabled: boolean;
|
|
51
|
+
cdpToken: string | null;
|
|
52
|
+
cdpFingerprint: string | null;
|
|
53
|
+
recommendedToken: string | null;
|
|
54
|
+
recommendedFingerprint: string | null;
|
|
55
|
+
warnings: string[];
|
|
56
|
+
issues: string[];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN';
|
|
60
|
+
|
|
61
|
+
function label(status: ReportStatus): string {
|
|
62
|
+
return `[${status}]`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function statusLine(status: ReportStatus, text: string): string {
|
|
66
|
+
return `${label(status)} ${text}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tokenSummary(token: string | null, fingerprint: string | null): string {
|
|
70
|
+
if (!token) return 'missing';
|
|
71
|
+
return `configured (${fingerprint})`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getDefaultShellRcPath(): string {
|
|
75
|
+
const shell = process.env.SHELL ?? '';
|
|
76
|
+
if (shell.endsWith('/bash')) return path.join(os.homedir(), '.bashrc');
|
|
77
|
+
if (shell.endsWith('/fish')) return path.join(os.homedir(), '.config', 'fish', 'config.fish');
|
|
78
|
+
return path.join(os.homedir(), '.zshrc');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
|
|
82
|
+
const home = os.homedir();
|
|
83
|
+
const candidates = [
|
|
84
|
+
path.join(home, '.codex', 'config.toml'),
|
|
85
|
+
path.join(home, '.codex', 'mcp.json'),
|
|
86
|
+
path.join(home, '.cursor', 'mcp.json'),
|
|
87
|
+
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
88
|
+
path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
89
|
+
path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
|
|
90
|
+
path.join(cwd, '.cursor', 'mcp.json'),
|
|
91
|
+
path.join(cwd, '.vscode', 'mcp.json'),
|
|
92
|
+
path.join(cwd, '.opencode', 'opencode.json'),
|
|
93
|
+
];
|
|
94
|
+
return [...new Set(candidates)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function readTokenFromShellContent(content: string): string | null {
|
|
98
|
+
const m = content.match(TOKEN_LINE_RE);
|
|
99
|
+
return m?.[3] ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function upsertShellToken(content: string, token: string): string {
|
|
103
|
+
const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
|
|
104
|
+
if (!content.trim()) return `${nextLine}\n`;
|
|
105
|
+
if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
|
|
106
|
+
token
|
|
107
|
+
}"`);
|
|
108
|
+
return `${content.replace(/\s*$/, '')}\n${nextLine}\n`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readJsonConfigToken(content: string): string | null {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(content);
|
|
114
|
+
return readTokenFromJsonObject(parsed);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readTokenFromJsonObject(parsed: any): string | null {
|
|
121
|
+
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
|
122
|
+
if (typeof direct === 'string' && direct) return direct;
|
|
123
|
+
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
|
124
|
+
if (typeof opencode === 'string' && opencode) return opencode;
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function upsertJsonConfigToken(content: string, token: string): string {
|
|
129
|
+
const parsed = content.trim() ? JSON.parse(content) : {};
|
|
130
|
+
if (parsed?.mcpServers) {
|
|
131
|
+
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
|
|
132
|
+
command: 'npx',
|
|
133
|
+
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
|
134
|
+
};
|
|
135
|
+
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
|
|
136
|
+
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
137
|
+
} else {
|
|
138
|
+
parsed.mcp = parsed.mcp ?? {};
|
|
139
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
|
|
140
|
+
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
|
|
141
|
+
enabled: true,
|
|
142
|
+
type: 'local',
|
|
143
|
+
};
|
|
144
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].env = parsed.mcp[PLAYWRIGHT_SERVER_NAME].env ?? {};
|
|
145
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
146
|
+
}
|
|
147
|
+
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function readTomlConfigToken(content: string): string | null {
|
|
151
|
+
const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/);
|
|
152
|
+
if (!sectionMatch) return null;
|
|
153
|
+
const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m);
|
|
154
|
+
return tokenMatch?.[1] ?? null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function upsertTomlConfigToken(content: string, token: string): string {
|
|
158
|
+
const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/;
|
|
159
|
+
const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`;
|
|
160
|
+
if (envSectionRe.test(content)) {
|
|
161
|
+
return content.replace(envSectionRe, (section) => {
|
|
162
|
+
if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) {
|
|
163
|
+
return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine);
|
|
164
|
+
}
|
|
165
|
+
return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/;
|
|
170
|
+
if (baseSectionRe.test(content)) {
|
|
171
|
+
return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
|
|
175
|
+
return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function fileExists(filePath: string): boolean {
|
|
179
|
+
try {
|
|
180
|
+
return fs.existsSync(filePath);
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function canWrite(filePath: string): boolean {
|
|
187
|
+
try {
|
|
188
|
+
if (fileExists(filePath)) {
|
|
189
|
+
fs.accessSync(filePath, fs.constants.W_OK);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
fs.accessSync(path.dirname(filePath), fs.constants.W_OK);
|
|
193
|
+
return true;
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readConfigStatus(filePath: string): McpConfigStatus {
|
|
200
|
+
const format: McpConfigFormat = filePath.endsWith('.toml') ? 'toml' : 'json';
|
|
201
|
+
if (!fileExists(filePath)) {
|
|
202
|
+
return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) };
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
206
|
+
const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
|
|
207
|
+
return {
|
|
208
|
+
path: filePath,
|
|
209
|
+
exists: true,
|
|
210
|
+
format,
|
|
211
|
+
token,
|
|
212
|
+
fingerprint: getTokenFingerprint(token ?? undefined),
|
|
213
|
+
writable: canWrite(filePath),
|
|
214
|
+
};
|
|
215
|
+
} catch (error: any) {
|
|
216
|
+
return {
|
|
217
|
+
path: filePath,
|
|
218
|
+
exists: true,
|
|
219
|
+
format,
|
|
220
|
+
token: null,
|
|
221
|
+
fingerprint: null,
|
|
222
|
+
writable: canWrite(filePath),
|
|
223
|
+
parseError: error?.message ?? String(error),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function extractTokenViaCdp(): Promise<string | null> {
|
|
229
|
+
if (!(process.env.OPENCLI_USE_CDP === '1' || process.env.OPENCLI_CDP_ENDPOINT))
|
|
230
|
+
return null;
|
|
231
|
+
const candidates = [
|
|
232
|
+
`chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/options.html`,
|
|
233
|
+
`chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/popup.html`,
|
|
234
|
+
`chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/connect.html`,
|
|
235
|
+
`chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/index.html`,
|
|
236
|
+
];
|
|
237
|
+
const result = await browserSession(PlaywrightMCP, async (page: IPage) => {
|
|
238
|
+
for (const url of candidates) {
|
|
239
|
+
try {
|
|
240
|
+
await page.goto(url);
|
|
241
|
+
await page.wait(1);
|
|
242
|
+
const token = await page.evaluate(`() => {
|
|
243
|
+
const values = new Set();
|
|
244
|
+
const push = (value) => {
|
|
245
|
+
if (!value || typeof value !== 'string') return;
|
|
246
|
+
for (const match of value.matchAll(/[A-Za-z0-9_-]{24,}/g)) values.add(match[0]);
|
|
247
|
+
};
|
|
248
|
+
document.querySelectorAll('input, textarea, code, pre, span, div').forEach((el) => {
|
|
249
|
+
push(el.value);
|
|
250
|
+
push(el.textContent || '');
|
|
251
|
+
push(el.getAttribute && el.getAttribute('value'));
|
|
252
|
+
});
|
|
253
|
+
return Array.from(values);
|
|
254
|
+
}`);
|
|
255
|
+
const matches = Array.isArray(token) ? token.filter((v: string) => v.length >= 24) : [];
|
|
256
|
+
if (matches.length > 0) return matches.sort((a: string, b: string) => b.length - a.length)[0];
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
});
|
|
261
|
+
return typeof result === 'string' && result ? result : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
|
265
|
+
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
|
266
|
+
const remoteDebuggingEndpoint = await discoverChromeEndpoint().catch(() => null);
|
|
267
|
+
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
|
268
|
+
const shellFiles: ShellFileStatus[] = [shellPath].map((filePath) => {
|
|
269
|
+
if (!fileExists(filePath)) return { path: filePath, exists: false, token: null, fingerprint: null };
|
|
270
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
271
|
+
const token = readTokenFromShellContent(content);
|
|
272
|
+
return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
|
|
273
|
+
});
|
|
274
|
+
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
|
|
275
|
+
const configs = configPaths.map(readConfigStatus);
|
|
276
|
+
const cdpToken = !opts.token && !envToken ? await extractTokenViaCdp().catch(() => null) : null;
|
|
277
|
+
|
|
278
|
+
const allTokens = [
|
|
279
|
+
opts.token ?? null,
|
|
280
|
+
envToken,
|
|
281
|
+
...shellFiles.map(s => s.token),
|
|
282
|
+
...configs.map(c => c.token),
|
|
283
|
+
cdpToken,
|
|
284
|
+
].filter((v): v is string => !!v);
|
|
285
|
+
const uniqueTokens = [...new Set(allTokens)];
|
|
286
|
+
const recommendedToken = opts.token ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : cdpToken) ?? null;
|
|
287
|
+
|
|
288
|
+
const report: DoctorReport = {
|
|
289
|
+
cliVersion: opts.cliVersion,
|
|
290
|
+
envToken,
|
|
291
|
+
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
|
292
|
+
shellFiles,
|
|
293
|
+
configs,
|
|
294
|
+
remoteDebuggingEnabled: !!remoteDebuggingEndpoint,
|
|
295
|
+
remoteDebuggingEndpoint,
|
|
296
|
+
cdpEnabled: process.env.OPENCLI_USE_CDP === '1' || !!process.env.OPENCLI_CDP_ENDPOINT,
|
|
297
|
+
cdpToken,
|
|
298
|
+
cdpFingerprint: getTokenFingerprint(cdpToken ?? undefined),
|
|
299
|
+
recommendedToken,
|
|
300
|
+
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
|
301
|
+
warnings: [],
|
|
302
|
+
issues: [],
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
|
306
|
+
if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
|
|
307
|
+
if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
|
308
|
+
if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
|
309
|
+
if (!report.remoteDebuggingEnabled) report.warnings.push('Chrome remote debugging appears to be disabled or Chrome is not currently exposing a DevTools endpoint.');
|
|
310
|
+
for (const config of configs) {
|
|
311
|
+
if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
|
312
|
+
}
|
|
313
|
+
if (!recommendedToken) {
|
|
314
|
+
if (report.cdpEnabled) report.warnings.push('CDP is enabled, but no token could be extracted automatically from the extension UI.');
|
|
315
|
+
else report.warnings.push('No token source found. Enable OPENCLI_USE_CDP=1 to allow a best-effort token read from the extension page.');
|
|
316
|
+
}
|
|
317
|
+
return report;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
321
|
+
const tokenFingerprints = [
|
|
322
|
+
report.envFingerprint,
|
|
323
|
+
...report.shellFiles.map(shell => shell.fingerprint),
|
|
324
|
+
...report.configs.filter(config => config.exists).map(config => config.fingerprint),
|
|
325
|
+
].filter((value): value is string => !!value);
|
|
326
|
+
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
|
327
|
+
const hasMismatch = uniqueFingerprints.length > 1;
|
|
328
|
+
const lines = [`opencli v${report.cliVersion ?? 'unknown'} doctor`, ''];
|
|
329
|
+
lines.push(statusLine(report.remoteDebuggingEnabled ? 'OK' : 'WARN', `Chrome remote debugging: ${report.remoteDebuggingEnabled ? 'enabled' : 'disabled'}`));
|
|
330
|
+
if (report.remoteDebuggingEndpoint) lines.push(` ${report.remoteDebuggingEndpoint}`);
|
|
331
|
+
|
|
332
|
+
const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
333
|
+
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
|
|
334
|
+
|
|
335
|
+
for (const shell of report.shellFiles) {
|
|
336
|
+
const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
337
|
+
lines.push(statusLine(shellStatus, `Shell file ${shell.path}: ${tokenSummary(shell.token, shell.fingerprint)}`));
|
|
338
|
+
}
|
|
339
|
+
const existingConfigs = report.configs.filter(config => config.exists);
|
|
340
|
+
const missingConfigCount = report.configs.length - existingConfigs.length;
|
|
341
|
+
if (existingConfigs.length > 0) {
|
|
342
|
+
for (const config of existingConfigs) {
|
|
343
|
+
const parseSuffix = config.parseError ? ` (parse error: ${config.parseError})` : '';
|
|
344
|
+
const configStatus: ReportStatus = config.parseError
|
|
345
|
+
? 'WARN'
|
|
346
|
+
: !config.token
|
|
347
|
+
? 'MISSING'
|
|
348
|
+
: hasMismatch
|
|
349
|
+
? 'MISMATCH'
|
|
350
|
+
: 'OK';
|
|
351
|
+
lines.push(statusLine(configStatus, `MCP config ${config.path}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
lines.push(statusLine('MISSING', 'MCP config: no existing config files found in scanned locations'));
|
|
355
|
+
}
|
|
356
|
+
if (missingConfigCount > 0) lines.push(` Other scanned config locations not present: ${missingConfigCount}`);
|
|
357
|
+
if (report.cdpEnabled) {
|
|
358
|
+
const cdpStatus: ReportStatus = report.cdpToken ? 'OK' : 'WARN';
|
|
359
|
+
lines.push(statusLine(cdpStatus, `CDP token probe: ${tokenSummary(report.cdpToken, report.cdpFingerprint)}`));
|
|
360
|
+
}
|
|
361
|
+
lines.push('');
|
|
362
|
+
lines.push(statusLine(
|
|
363
|
+
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
|
|
364
|
+
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
|
|
365
|
+
));
|
|
366
|
+
if (report.issues.length) {
|
|
367
|
+
lines.push('', 'Issues:');
|
|
368
|
+
for (const issue of report.issues) lines.push(`- ${issue}`);
|
|
369
|
+
}
|
|
370
|
+
if (report.warnings.length) {
|
|
371
|
+
lines.push('', 'Warnings:');
|
|
372
|
+
for (const warning of report.warnings) lines.push(`- ${warning}`);
|
|
373
|
+
}
|
|
374
|
+
return lines.join('\n');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function confirmPrompt(question: string): Promise<boolean> {
|
|
378
|
+
const rl = createInterface({ input, output });
|
|
379
|
+
try {
|
|
380
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
381
|
+
return answer === 'y' || answer === 'yes';
|
|
382
|
+
} finally {
|
|
383
|
+
rl.close();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function writeFileWithMkdir(filePath: string, content: string): void {
|
|
388
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
389
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
|
|
393
|
+
const token = opts.token ?? report.recommendedToken;
|
|
394
|
+
if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token or enable CDP token probing first.');
|
|
395
|
+
|
|
396
|
+
const plannedWrites: string[] = [];
|
|
397
|
+
const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
|
|
398
|
+
plannedWrites.push(shellPath);
|
|
399
|
+
for (const config of report.configs) {
|
|
400
|
+
if (!config.writable) continue;
|
|
401
|
+
plannedWrites.push(config.path);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!opts.yes) {
|
|
405
|
+
const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${getTokenFingerprint(token)}?`);
|
|
406
|
+
if (!ok) return [];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const written: string[] = [];
|
|
410
|
+
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
|
|
411
|
+
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
|
|
412
|
+
written.push(shellPath);
|
|
413
|
+
|
|
414
|
+
for (const config of report.configs) {
|
|
415
|
+
if (!config.writable || config.parseError) continue;
|
|
416
|
+
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
|
|
417
|
+
const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
|
|
418
|
+
writeFileWithMkdir(config.path, next);
|
|
419
|
+
written.push(config.path);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
423
|
+
return written;
|
|
424
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -31,11 +31,29 @@ program.name('opencli').description('Make any website your CLI. Zero setup. AI-p
|
|
|
31
31
|
|
|
32
32
|
// ── Built-in commands ──────────────────────────────────────────────────────
|
|
33
33
|
|
|
34
|
-
program.command('list').description('List all available CLI commands').option('--json', 'JSON output')
|
|
34
|
+
program.command('list').description('List all available CLI commands').option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table').option('--json', 'JSON output (deprecated)')
|
|
35
35
|
.action((opts) => {
|
|
36
36
|
const registry = getRegistry();
|
|
37
37
|
const commands = [...registry.values()].sort((a, b) => fullName(a).localeCompare(fullName(b)));
|
|
38
|
-
|
|
38
|
+
const rows = commands.map(c => ({
|
|
39
|
+
command: fullName(c),
|
|
40
|
+
site: c.site,
|
|
41
|
+
name: c.name,
|
|
42
|
+
description: c.description,
|
|
43
|
+
strategy: strategyLabel(c),
|
|
44
|
+
browser: c.browser,
|
|
45
|
+
args: c.args.map(a => a.name).join(', '),
|
|
46
|
+
}));
|
|
47
|
+
const fmt = opts.json && opts.format === 'table' ? 'json' : opts.format;
|
|
48
|
+
if (fmt !== 'table') {
|
|
49
|
+
renderOutput(rows, {
|
|
50
|
+
fmt,
|
|
51
|
+
columns: ['command', 'site', 'name', 'description', 'strategy', 'browser', 'args'],
|
|
52
|
+
title: 'opencli/list',
|
|
53
|
+
source: 'opencli list',
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
39
57
|
const sites = new Map<string, CliCommand[]>();
|
|
40
58
|
for (const cmd of commands) { const g = sites.get(cmd.site) ?? []; g.push(cmd); sites.set(cmd.site, g); }
|
|
41
59
|
console.log(); console.log(chalk.bold(' opencli') + chalk.dim(' — available commands')); console.log();
|
|
@@ -73,6 +91,30 @@ program.command('cascade').description('Strategy cascade: find simplest working
|
|
|
73
91
|
console.log(renderCascadeResult(result));
|
|
74
92
|
});
|
|
75
93
|
|
|
94
|
+
program.command('doctor')
|
|
95
|
+
.description('Diagnose Playwright MCP Bridge, token consistency, and Chrome remote debugging')
|
|
96
|
+
.option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
|
|
97
|
+
.option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
|
|
98
|
+
.option('--token <token>', 'Override token to write instead of auto-detecting')
|
|
99
|
+
.option('--shell-rc <path>', 'Shell startup file to update')
|
|
100
|
+
.option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
|
|
101
|
+
.action(async (opts) => {
|
|
102
|
+
const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
|
|
103
|
+
const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined;
|
|
104
|
+
const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
|
105
|
+
console.log(renderBrowserDoctorReport(report));
|
|
106
|
+
if (opts.fix) {
|
|
107
|
+
const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
|
|
108
|
+
console.log();
|
|
109
|
+
if (written.length > 0) {
|
|
110
|
+
console.log(chalk.green('Updated files:'));
|
|
111
|
+
for (const filePath of written) console.log(`- ${filePath}`);
|
|
112
|
+
} else {
|
|
113
|
+
console.log(chalk.yellow('No files were changed.'));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
76
118
|
// ── Dynamic site commands ──────────────────────────────────────────────────
|
|
77
119
|
|
|
78
120
|
const registry = getRegistry();
|
|
@@ -89,7 +131,7 @@ for (const [, cmd] of registry) {
|
|
|
89
131
|
else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default));
|
|
90
132
|
else subCmd.option(flag, arg.help ?? '');
|
|
91
133
|
}
|
|
92
|
-
subCmd.option('-f, --format <fmt>', 'Output format: table, json, md, csv', 'table').option('-v, --verbose', 'Debug output', false);
|
|
134
|
+
subCmd.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table').option('-v, --verbose', 'Debug output', false);
|
|
93
135
|
|
|
94
136
|
subCmd.action(async (actionOpts) => {
|
|
95
137
|
const startTime = Date.now();
|
|
@@ -99,9 +141,10 @@ for (const [, cmd] of registry) {
|
|
|
99
141
|
else if (arg.default != null) kwargs[arg.name] = arg.default;
|
|
100
142
|
}
|
|
101
143
|
try {
|
|
144
|
+
if (actionOpts.verbose) process.env.OPENCLI_VERBOSE = '1';
|
|
102
145
|
let result: any;
|
|
103
146
|
if (cmd.browser) {
|
|
104
|
-
result = await browserSession(PlaywrightMCP, async (page) => runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) }));
|
|
147
|
+
result = await browserSession(PlaywrightMCP, async (page) => runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) }), { forceExtension: cmd.forceExtension });
|
|
105
148
|
} else { result = await executeCommand(cmd, null, kwargs, actionOpts.verbose); }
|
|
106
149
|
if (actionOpts.verbose && (!result || (Array.isArray(result) && result.length === 0))) {
|
|
107
150
|
console.error(chalk.yellow(`[Verbose] Warning: Command returned an empty result. If the website structural API changed or requires authentication, check the network or update the adapter.`));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { render } from './output.js';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe('render', () => {
|
|
9
|
+
it('renders YAML output', () => {
|
|
10
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
11
|
+
|
|
12
|
+
render([{ title: 'Hello', rank: 1 }], { fmt: 'yaml' });
|
|
13
|
+
|
|
14
|
+
expect(log).toHaveBeenCalledOnce();
|
|
15
|
+
expect(log.mock.calls[0]?.[0]).toContain('- title: Hello');
|
|
16
|
+
expect(log.mock.calls[0]?.[0]).toContain('rank: 1');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders yml alias as YAML output', () => {
|
|
20
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
21
|
+
|
|
22
|
+
render({ title: 'Hello' }, { fmt: 'yml' });
|
|
23
|
+
|
|
24
|
+
expect(log).toHaveBeenCalledOnce();
|
|
25
|
+
expect(log.mock.calls[0]?.[0]).toContain('title: Hello');
|
|
26
|
+
});
|
|
27
|
+
});
|