feat(cli): add browser automation commands

This commit is contained in:
Tam Nhu Tran
2026-04-16 18:49:24 -04:00
parent b6aef885ad
commit 4e30c9b080
42 changed files with 3028 additions and 32 deletions
+100
View File
@@ -0,0 +1,100 @@
import * as path from 'path';
import type { BrowserConfig } from '../../config/unified-config-types';
import { getCcsDir } from '../config-manager';
import { expandPath } from '../helpers';
export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
export interface EffectiveClaudeBrowserAttachConfig {
enabled: boolean;
source: 'config' | BrowserOverrideSource;
overrideActive: boolean;
userDataDir: string;
devtoolsPort: number;
hasExplicitDevtoolsPort: boolean;
}
export function getRecommendedBrowserUserDataDir(): string {
return path.join(getCcsDir(), 'browser', 'chrome-user-data');
}
export function resolveBrowserUserDataDir(value?: string): string | undefined {
return value?.trim() ? expandPath(value) : undefined;
}
export function getBrowserAttachOverride(env: NodeJS.ProcessEnv = process.env): {
userDataDir?: string;
devtoolsPort?: number;
source?: BrowserOverrideSource;
} {
const explicitUserDataDir = resolveBrowserUserDataDir(env.CCS_BROWSER_USER_DATA_DIR);
if (explicitUserDataDir) {
return {
userDataDir: explicitUserDataDir,
devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT),
source: 'CCS_BROWSER_USER_DATA_DIR',
};
}
const legacyProfileDir = resolveBrowserUserDataDir(env.CCS_BROWSER_PROFILE_DIR);
if (legacyProfileDir) {
return {
userDataDir: legacyProfileDir,
devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT),
source: 'CCS_BROWSER_PROFILE_DIR',
};
}
return {};
}
export function getEffectiveClaudeBrowserAttachConfig(
config: BrowserConfig,
env: NodeJS.ProcessEnv = process.env
): EffectiveClaudeBrowserAttachConfig {
const override = getBrowserAttachOverride(env);
const configUserDataDir =
resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir();
const configPort = normalizeDevtoolsPort(config.claude.devtools_port);
if (override.userDataDir) {
return {
enabled: true,
source: override.source as BrowserOverrideSource,
overrideActive: true,
userDataDir: override.userDataDir,
devtoolsPort: override.devtoolsPort ?? configPort,
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
};
}
return {
enabled: config.claude.enabled,
source: 'config',
overrideActive: false,
userDataDir: configUserDataDir,
devtoolsPort: configPort,
hasExplicitDevtoolsPort: true,
};
}
function parseDevtoolsPort(value?: string): number | undefined {
if (!value?.trim() || !/^\d+$/.test(value.trim())) {
return undefined;
}
return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10));
}
function normalizeDevtoolsPort(value: number | undefined): number {
if (!Number.isFinite(value)) {
return 9222;
}
const port = Math.floor(value as number);
if (port < 1 || port > 65535) {
return 9222;
}
return port;
}
+193
View File
@@ -0,0 +1,193 @@
import { getBrowserConfig } from '../../config/unified-config-loader';
import { getCodexBinaryInfo } from '../../targets/codex-detector';
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer';
import {
getEffectiveClaudeBrowserAttachConfig,
getRecommendedBrowserUserDataDir,
} from './browser-settings';
export interface BrowserLaunchCommands {
darwin: string;
linux: string;
win32: string;
}
export interface ClaudeBrowserStatus {
enabled: boolean;
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
overrideActive: boolean;
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
title: string;
detail: string;
nextStep: string;
effectiveUserDataDir: string;
recommendedUserDataDir: string;
devtoolsPort: number;
managedMcpServerName: string;
managedMcpServerPath: string;
launchCommands: BrowserLaunchCommands;
runtimeEnv?: BrowserRuntimeEnv;
}
export interface CodexBrowserStatus {
enabled: boolean;
state: 'disabled' | 'enabled' | 'unsupported_build';
title: string;
detail: string;
nextStep: string;
serverName: string;
supportsConfigOverrides: boolean;
binaryPath: string | null;
version?: string;
}
export interface BrowserStatusPayload {
claude: ClaudeBrowserStatus;
codex: CodexBrowserStatus;
}
export async function getBrowserStatus(): Promise<BrowserStatusPayload> {
const browserConfig = getBrowserConfig();
return {
claude: await buildClaudeBrowserStatus(browserConfig),
codex: buildCodexBrowserStatus(browserConfig),
};
}
async function buildClaudeBrowserStatus(
browserConfig = getBrowserConfig()
): Promise<ClaudeBrowserStatus> {
const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort);
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
enabled: effective.enabled,
source: effective.source,
overrideActive: effective.overrideActive,
effectiveUserDataDir: effective.userDataDir,
recommendedUserDataDir: getRecommendedBrowserUserDataDir(),
devtoolsPort: effective.devtoolsPort,
managedMcpServerName: getBrowserMcpServerName(),
managedMcpServerPath: getBrowserMcpServerPath(),
launchCommands,
};
if (!effective.enabled) {
return {
...base,
state: 'disabled',
title: 'Claude Browser Attach is disabled.',
detail:
'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.',
nextStep:
'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.',
};
}
try {
const runtimeEnv = await resolveBrowserRuntimeEnv({
profileDir: effective.userDataDir,
devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined,
});
return {
...base,
state: 'ready',
title: 'Claude Browser Attach is ready.',
detail:
'CCS can reach the configured Chrome DevTools endpoint for the current attach session.',
nextStep: 'Launch a Claude-target CCS session to use the managed browser MCP runtime.',
runtimeEnv,
};
} catch (error) {
const message = (error as Error).message;
if (message.includes('Chrome profile directory is invalid')) {
return {
...base,
state: 'path_missing',
title: 'Claude Browser Attach path is missing.',
detail: message,
nextStep: `Create or choose a Chrome user-data directory, then launch Chrome with attach mode enabled. Example: ${launchCommands[platformKey()]}`,
};
}
if (message.includes('Chrome reuse metadata')) {
return {
...base,
state: 'browser_not_running',
title: 'Claude Browser Attach could not find a running browser session.',
detail: message,
nextStep: `Start Chrome with remote debugging and the configured user-data dir. Example: ${launchCommands[platformKey()]}`,
};
}
return {
...base,
state: 'endpoint_unreachable',
title: 'Claude Browser Attach could not reach the DevTools endpoint.',
detail: message,
nextStep: `Restart the attach browser session or confirm the configured port. Example: ${launchCommands[platformKey()]}`,
};
}
}
function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrowserStatus {
if (!browserConfig.codex.enabled) {
return {
enabled: false,
state: 'disabled',
title: 'Codex Browser Tools are disabled.',
detail: 'CCS will not inject Playwright MCP browser tooling into Codex-target launches.',
nextStep:
'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.',
serverName: 'ccs_browser',
supportsConfigOverrides: false,
binaryPath: null,
};
}
const binaryInfo = getCodexBinaryInfo({ includeVersion: true, includeFeatures: true });
const supportsConfigOverrides = Boolean(binaryInfo?.features?.includes('config-overrides'));
if (!binaryInfo || !supportsConfigOverrides) {
return {
enabled: true,
state: 'unsupported_build',
title: 'Codex Browser Tools need a Codex build with --config override support.',
detail: binaryInfo
? `Detected Codex at ${binaryInfo.path}, but it does not advertise --config overrides.`
: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.',
nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.',
serverName: 'ccs_browser',
supportsConfigOverrides,
binaryPath: binaryInfo?.path ?? null,
version: binaryInfo?.version,
};
}
return {
enabled: true,
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.',
nextStep: 'Use a Codex-target CCS launch to access browser tools.',
serverName: 'ccs_browser',
supportsConfigOverrides,
binaryPath: binaryInfo.path,
version: binaryInfo.version,
};
}
function buildLaunchCommands(userDataDir: string, devtoolsPort: number): BrowserLaunchCommands {
const quotedPath = JSON.stringify(userDataDir);
return {
darwin: `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
linux: `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
win32: `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
};
}
function platformKey(): keyof BrowserLaunchCommands {
if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32';
return 'linux';
}
+13
View File
@@ -17,9 +17,22 @@ export {
export { appendBrowserToolArgs } from './claude-tool-args';
export {
getRecommendedBrowserUserDataDir,
getBrowserAttachOverride,
getEffectiveClaudeBrowserAttachConfig,
} from './browser-settings';
export {
resolveBrowserRuntimeEnv,
resolveDefaultChromeUserDataDir,
resolveConfiguredBrowserProfileDir,
} from './chrome-reuse';
export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse';
export { getBrowserStatus } from './browser-status';
export type {
BrowserStatusPayload,
ClaudeBrowserStatus,
CodexBrowserStatus,
} from './browser-status';