mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
新增 browser MCP 安装、Chrome 复用与运行时接线, 实现 navigate、click、type、take_screenshot 四个 phase-1 工具并补齐相关单测。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
376 lines
9.9 KiB
TypeScript
376 lines
9.9 KiB
TypeScript
/**
|
|
* Browser MCP installer and ~/.claude.json provisioning.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as lockfile from 'proper-lockfile';
|
|
import { InstanceManager } from '../../management/instance-manager';
|
|
import { getClaudeUserConfigPath } from '../claude-config-path';
|
|
import { getCcsDir } from '../config-manager';
|
|
|
|
const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs';
|
|
const BROWSER_MCP_SERVER_NAME = 'ccs-browser';
|
|
|
|
interface ClaudeUserConfig {
|
|
mcpServers?: Record<string, unknown>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface ManagedBrowserMcpConfig {
|
|
type: 'stdio';
|
|
command: 'node';
|
|
args: [string];
|
|
env: Record<string, string>;
|
|
}
|
|
|
|
function resolvePackageRoot(fromPath: string): string | null {
|
|
let currentDir = path.dirname(fromPath);
|
|
while (true) {
|
|
if (fs.existsSync(path.join(currentDir, 'package.json'))) {
|
|
return currentDir;
|
|
}
|
|
const parentDir = path.dirname(currentDir);
|
|
if (parentDir === currentDir) {
|
|
return null;
|
|
}
|
|
currentDir = parentDir;
|
|
}
|
|
}
|
|
|
|
function getBrowserMcpServerEnv(): Record<string, string> {
|
|
const sourcePath = resolveBundledServerSourcePath();
|
|
if (!sourcePath) {
|
|
return {};
|
|
}
|
|
|
|
const packageRoot = resolvePackageRoot(sourcePath);
|
|
if (!packageRoot) {
|
|
return {};
|
|
}
|
|
|
|
const nodeModulesPath = path.join(packageRoot, 'node_modules');
|
|
return fs.existsSync(nodeModulesPath) ? { NODE_PATH: nodeModulesPath } : {};
|
|
}
|
|
|
|
function getCcsMcpDir(): string {
|
|
return path.join(getCcsDir(), 'mcp');
|
|
}
|
|
|
|
export function getBrowserMcpServerName(): string {
|
|
return BROWSER_MCP_SERVER_NAME;
|
|
}
|
|
|
|
export function getBrowserMcpServerPath(): string {
|
|
return path.join(getCcsMcpDir(), BROWSER_MCP_SERVER);
|
|
}
|
|
|
|
function hasMatchingContents(sourcePath: string, destinationPath: string): boolean {
|
|
if (!fs.existsSync(destinationPath)) {
|
|
return false;
|
|
}
|
|
|
|
const source = fs.readFileSync(sourcePath);
|
|
try {
|
|
const destination = fs.readFileSync(destinationPath);
|
|
return source.equals(destination);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getTempPath(targetPath: string): string {
|
|
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
return `${targetPath}.${suffix}.tmp`;
|
|
}
|
|
|
|
function resolveBundledServerSourcePath(): string | null {
|
|
const possiblePaths = [
|
|
path.join(__dirname, '..', '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
|
|
path.join(__dirname, '..', '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
|
|
path.join(__dirname, '..', 'lib', 'mcp', BROWSER_MCP_SERVER),
|
|
];
|
|
|
|
for (const candidate of possiblePaths) {
|
|
if (fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null {
|
|
if (!fs.existsSync(configPath)) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const raw = fs.readFileSync(configPath, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
return null;
|
|
}
|
|
return parsed as ClaudeUserConfig;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean {
|
|
const tempPath = getTempPath(configPath);
|
|
const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600;
|
|
|
|
try {
|
|
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
fs.chmodSync(tempPath, fileMode);
|
|
fs.renameSync(tempPath, configPath);
|
|
return true;
|
|
} finally {
|
|
if (fs.existsSync(tempPath)) {
|
|
fs.unlinkSync(tempPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
|
|
const configDir = path.dirname(configPath);
|
|
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
|
|
let release: (() => void) | undefined;
|
|
|
|
if (!fs.existsSync(configDir)) {
|
|
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
}
|
|
|
|
if (!fs.existsSync(lockTarget)) {
|
|
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
|
|
}
|
|
|
|
try {
|
|
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
|
|
return callback();
|
|
} finally {
|
|
if (release) {
|
|
try {
|
|
release();
|
|
} catch {
|
|
// Best-effort release.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function isLockUnavailableError(error: unknown): boolean {
|
|
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
|
|
}
|
|
|
|
function removeManagedServerConfig(configPath: string): boolean {
|
|
if (!fs.existsSync(configPath)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return withClaudeUserConfigLock(configPath, () => {
|
|
const config = readClaudeUserConfig(configPath);
|
|
if (config === null) {
|
|
return false;
|
|
}
|
|
|
|
const existingServers =
|
|
config.mcpServers &&
|
|
typeof config.mcpServers === 'object' &&
|
|
!Array.isArray(config.mcpServers)
|
|
? { ...(config.mcpServers as Record<string, unknown>) }
|
|
: {};
|
|
|
|
if (!(BROWSER_MCP_SERVER_NAME in existingServers)) {
|
|
return false;
|
|
}
|
|
|
|
delete existingServers[BROWSER_MCP_SERVER_NAME];
|
|
|
|
const nextConfig: ClaudeUserConfig = { ...config };
|
|
if (Object.keys(existingServers).length === 0) {
|
|
delete nextConfig.mcpServers;
|
|
} else {
|
|
nextConfig.mcpServers = existingServers;
|
|
}
|
|
|
|
try {
|
|
return writeClaudeUserConfig(configPath, nextConfig);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (isLockUnavailableError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function installBrowserMcpServer(): boolean {
|
|
const sourcePath = resolveBundledServerSourcePath();
|
|
if (!sourcePath) {
|
|
return false;
|
|
}
|
|
|
|
const mcpDir = getCcsMcpDir();
|
|
if (!fs.existsSync(mcpDir)) {
|
|
fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 });
|
|
}
|
|
|
|
const serverPath = getBrowserMcpServerPath();
|
|
const sourceMode = fs.statSync(sourcePath).mode & 0o777;
|
|
if (hasMatchingContents(sourcePath, serverPath)) {
|
|
if ((fs.statSync(serverPath).mode & 0o777) !== sourceMode) {
|
|
fs.chmodSync(serverPath, sourceMode);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const tempPath = getTempPath(serverPath);
|
|
|
|
try {
|
|
fs.copyFileSync(sourcePath, tempPath);
|
|
fs.chmodSync(tempPath, sourceMode);
|
|
try {
|
|
fs.renameSync(tempPath, serverPath);
|
|
} catch (renameError) {
|
|
const errorCode = (renameError as NodeJS.ErrnoException).code;
|
|
if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') {
|
|
throw renameError;
|
|
}
|
|
|
|
if (!hasMatchingContents(sourcePath, serverPath)) {
|
|
fs.copyFileSync(tempPath, serverPath);
|
|
fs.chmodSync(serverPath, sourceMode);
|
|
}
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
if (fs.existsSync(tempPath)) {
|
|
fs.unlinkSync(tempPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function ensureBrowserMcpConfig(): boolean {
|
|
const claudeUserConfigPath = getClaudeUserConfigPath();
|
|
const claudeUserConfigDir = path.dirname(claudeUserConfigPath);
|
|
if (!fs.existsSync(claudeUserConfigDir)) {
|
|
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
|
|
}
|
|
|
|
const desiredServerConfig: ManagedBrowserMcpConfig = {
|
|
type: 'stdio',
|
|
command: 'node',
|
|
args: [getBrowserMcpServerPath()],
|
|
env: getBrowserMcpServerEnv(),
|
|
};
|
|
|
|
try {
|
|
return withClaudeUserConfigLock(claudeUserConfigPath, () => {
|
|
const config = readClaudeUserConfig(claudeUserConfigPath);
|
|
if (config === null) {
|
|
return false;
|
|
}
|
|
|
|
const existingServers =
|
|
config.mcpServers &&
|
|
typeof config.mcpServers === 'object' &&
|
|
!Array.isArray(config.mcpServers)
|
|
? (config.mcpServers as Record<string, unknown>)
|
|
: {};
|
|
const currentConfig = existingServers[BROWSER_MCP_SERVER_NAME];
|
|
if (
|
|
typeof currentConfig === 'object' &&
|
|
currentConfig !== null &&
|
|
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const nextConfig: ClaudeUserConfig = {
|
|
...config,
|
|
mcpServers: {
|
|
...existingServers,
|
|
[BROWSER_MCP_SERVER_NAME]: desiredServerConfig,
|
|
},
|
|
};
|
|
|
|
try {
|
|
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (isLockUnavailableError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function ensureBrowserMcp(): boolean {
|
|
const installed = installBrowserMcpServer();
|
|
const configured = installed && ensureBrowserMcpConfig();
|
|
return installed && configured;
|
|
}
|
|
|
|
export function syncBrowserMcpToConfigDir(claudeConfigDir: string | undefined): boolean {
|
|
if (!claudeConfigDir) {
|
|
return false;
|
|
}
|
|
|
|
return new InstanceManager().syncMcpServers(claudeConfigDir);
|
|
}
|
|
|
|
export function uninstallBrowserMcpServer(): boolean {
|
|
const serverPath = getBrowserMcpServerPath();
|
|
if (!fs.existsSync(serverPath)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
fs.unlinkSync(serverPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function removeBrowserMcpConfig(): boolean {
|
|
let removed = removeManagedServerConfig(getClaudeUserConfigPath());
|
|
|
|
const instanceManager = new InstanceManager();
|
|
for (const instanceName of instanceManager.listInstances()) {
|
|
const instancePath = instanceManager.getInstancePath(instanceName);
|
|
const instanceClaudeConfigPath = path.join(instancePath, '.claude.json');
|
|
removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed;
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
export function uninstallBrowserMcp(): boolean {
|
|
const removedConfig = removeBrowserMcpConfig();
|
|
const removedServer = uninstallBrowserMcpServer();
|
|
return removedConfig || removedServer;
|
|
}
|
|
|
|
export function ensureBrowserMcpOrThrow(): boolean {
|
|
const ready = ensureBrowserMcp();
|
|
if (!ready) {
|
|
throw new Error('Browser MCP is enabled, but CCS could not prepare the local browser tool.');
|
|
}
|
|
|
|
return ready;
|
|
}
|