mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 21:25:27 +02:00
feat: browse connect/disconnect/focus CLI commands
- connect: pre-server command that discovers browser, starts server in CDP mode - disconnect: drops CDP connection, restarts in headless mode - focus: brings browser window to foreground via osascript (macOS) - status: now shows Mode: cdp | launched | headed - startServer() accepts extra env vars for CDP URL/port passthrough Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+68
-2
@@ -83,6 +83,8 @@ interface ServerState {
|
||||
startedAt: string;
|
||||
serverPath: string;
|
||||
binaryVersion?: string;
|
||||
mode?: 'launched' | 'cdp';
|
||||
cdpPort?: number;
|
||||
}
|
||||
|
||||
// ─── State File ────────────────────────────────────────────────
|
||||
@@ -161,7 +163,7 @@ function cleanupLegacyState(): void {
|
||||
}
|
||||
|
||||
// ─── Server Lifecycle ──────────────────────────────────────────
|
||||
async function startServer(): Promise<ServerState> {
|
||||
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
|
||||
ensureStateDir(config);
|
||||
|
||||
// Clean up stale state file
|
||||
@@ -176,7 +178,7 @@ async function startServer(): Promise<ServerState> {
|
||||
: ['bun', 'run', SERVER_SCRIPT];
|
||||
const proc = Bun.spawn(serverCmd, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, ...extraEnv },
|
||||
});
|
||||
|
||||
// Don't hold the CLI open
|
||||
@@ -342,6 +344,70 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
const command = args[0];
|
||||
const commandArgs = args.slice(1);
|
||||
|
||||
// ─── CDP Connect (pre-server command) ───────────────────────
|
||||
// connect must be handled BEFORE ensureServer() because it needs
|
||||
// to restart the server with CDP env vars.
|
||||
if (command === 'connect') {
|
||||
const { discoverAndConnect } = await import('./chrome-launcher');
|
||||
|
||||
// Parse args: connect [browser] [--port N]
|
||||
let preferredBrowser: string | undefined;
|
||||
let port = 9222;
|
||||
for (let i = 0; i < commandArgs.length; i++) {
|
||||
if (commandArgs[i] === '--port' && commandArgs[i + 1]) {
|
||||
port = parseInt(commandArgs[i + 1], 10);
|
||||
i++;
|
||||
} else if (!commandArgs[i].startsWith('-')) {
|
||||
preferredBrowser = commandArgs[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already in CDP mode
|
||||
const existingState = readState();
|
||||
if (existingState && existingState.mode === 'cdp') {
|
||||
console.log('Already connected to real browser via CDP.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Kill existing server if running
|
||||
if (existingState) {
|
||||
try { process.kill(existingState.pid, 'SIGTERM'); } catch {}
|
||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||
// Wait for clean shutdown
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Discover and connect to browser
|
||||
console.log(`Discovering browser${preferredBrowser ? ` (${preferredBrowser})` : ''}...`);
|
||||
try {
|
||||
const result = await discoverAndConnect(preferredBrowser, port);
|
||||
console.log(`Found ${result.browser} CDP at port ${result.port}`);
|
||||
|
||||
// Start server with CDP env vars
|
||||
const newState = await startServer({
|
||||
BROWSE_CDP_URL: result.wsUrl,
|
||||
BROWSE_CDP_PORT: String(result.port),
|
||||
});
|
||||
|
||||
// Print connected status
|
||||
const resp = await fetch(`http://127.0.0.1:${newState.port}/command`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${newState.token}`,
|
||||
},
|
||||
body: JSON.stringify({ command: 'tabs', args: [] }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const tabList = await resp.text();
|
||||
console.log(`Connected to ${result.browser} via CDP\n${tabList}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[browse] Connect failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Special case: chain reads from stdin
|
||||
if (command === 'chain' && commandArgs.length === 0) {
|
||||
const stdin = await Bun.stdin.text();
|
||||
|
||||
@@ -31,6 +31,7 @@ export const META_COMMANDS = new Set([
|
||||
'chain', 'diff',
|
||||
'url', 'snapshot',
|
||||
'handoff', 'resume',
|
||||
'connect', 'disconnect', 'focus',
|
||||
]);
|
||||
|
||||
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
||||
@@ -98,6 +99,10 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
// Handoff
|
||||
'handoff': { category: 'Server', description: 'Open visible Chrome at current page for user takeover', usage: 'handoff [message]' },
|
||||
'resume': { category: 'Server', description: 'Re-snapshot after user takeover, return control to AI', usage: 'resume' },
|
||||
// CDP
|
||||
'connect': { category: 'Server', description: 'Connect to real Chrome/Comet browser via CDP', usage: 'connect [browser] [--port N]' },
|
||||
'disconnect': { category: 'Server', description: 'Disconnect from real browser, return to headless mode' },
|
||||
'focus': { category: 'Server', description: 'Bring connected browser window to foreground (macOS)', usage: 'focus [@ref]' },
|
||||
};
|
||||
|
||||
// Load-time validation: descriptions must cover exactly the command sets
|
||||
|
||||
@@ -61,8 +61,10 @@ export async function handleMetaCommand(
|
||||
case 'status': {
|
||||
const page = bm.getPage();
|
||||
const tabs = bm.getTabCount();
|
||||
const mode = bm.getConnectionMode();
|
||||
return [
|
||||
`Status: healthy`,
|
||||
`Mode: ${mode}`,
|
||||
`URL: ${page.url()}`,
|
||||
`Tabs: ${tabs}`,
|
||||
`PID: ${process.pid}`,
|
||||
@@ -263,6 +265,69 @@ export async function handleMetaCommand(
|
||||
return `RESUMED\n${snapshot}`;
|
||||
}
|
||||
|
||||
// ─── CDP Connect ────────────────────────────────────
|
||||
case 'connect': {
|
||||
// connect is handled as a pre-server command in cli.ts
|
||||
// If we get here, server is already running — tell the user
|
||||
if (bm.getConnectionMode() === 'cdp') {
|
||||
return 'Already connected to real browser via CDP.';
|
||||
}
|
||||
return 'The connect command must be run from the CLI (not sent to a running server). Run: $B connect [browser]';
|
||||
}
|
||||
|
||||
case 'disconnect': {
|
||||
if (bm.getConnectionMode() !== 'cdp') {
|
||||
return 'Not in CDP mode — nothing to disconnect.';
|
||||
}
|
||||
// Signal that we want a restart in headless mode
|
||||
console.log('[browse] Disconnecting from real browser. Restarting in headless mode.');
|
||||
await shutdown();
|
||||
return 'Disconnected from real browser. Server will restart in headless mode on next command.';
|
||||
}
|
||||
|
||||
case 'focus': {
|
||||
if (bm.getConnectionMode() !== 'cdp') {
|
||||
return 'focus requires CDP mode. Run `$B connect` first.';
|
||||
}
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
// Detect which browser we're connected to from the CDP info
|
||||
// For now, try common app names
|
||||
const appNames = ['Comet', 'Google Chrome', 'Arc', 'Brave Browser', 'Microsoft Edge'];
|
||||
let activated = false;
|
||||
for (const appName of appNames) {
|
||||
try {
|
||||
execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000 });
|
||||
activated = true;
|
||||
break;
|
||||
} catch {
|
||||
// Try next browser
|
||||
}
|
||||
}
|
||||
|
||||
if (!activated) {
|
||||
return 'Could not bring browser to foreground. macOS only.';
|
||||
}
|
||||
|
||||
// If a ref was passed, scroll it into view
|
||||
if (args.length > 0 && args[0].startsWith('@')) {
|
||||
try {
|
||||
const resolved = await bm.resolveRef(args[0]);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||
return `Browser activated. Scrolled ${args[0]} into view.`;
|
||||
}
|
||||
} catch {
|
||||
// Ref not found — still activated the browser
|
||||
}
|
||||
}
|
||||
|
||||
return 'Browser window activated.';
|
||||
} catch (err: any) {
|
||||
return `focus failed: ${err.message}. macOS only.`;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown meta command: ${command}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user