mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-19 19:02:29 +08: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:
@@ -83,6 +83,8 @@ interface ServerState {
|
|||||||
startedAt: string;
|
startedAt: string;
|
||||||
serverPath: string;
|
serverPath: string;
|
||||||
binaryVersion?: string;
|
binaryVersion?: string;
|
||||||
|
mode?: 'launched' | 'cdp';
|
||||||
|
cdpPort?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── State File ────────────────────────────────────────────────
|
// ─── State File ────────────────────────────────────────────────
|
||||||
@@ -161,7 +163,7 @@ function cleanupLegacyState(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Server Lifecycle ──────────────────────────────────────────
|
// ─── Server Lifecycle ──────────────────────────────────────────
|
||||||
async function startServer(): Promise<ServerState> {
|
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
|
||||||
ensureStateDir(config);
|
ensureStateDir(config);
|
||||||
|
|
||||||
// Clean up stale state file
|
// Clean up stale state file
|
||||||
@@ -176,7 +178,7 @@ async function startServer(): Promise<ServerState> {
|
|||||||
: ['bun', 'run', SERVER_SCRIPT];
|
: ['bun', 'run', SERVER_SCRIPT];
|
||||||
const proc = Bun.spawn(serverCmd, {
|
const proc = Bun.spawn(serverCmd, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
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
|
// Don't hold the CLI open
|
||||||
@@ -342,6 +344,70 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
const command = args[0];
|
const command = args[0];
|
||||||
const commandArgs = args.slice(1);
|
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
|
// Special case: chain reads from stdin
|
||||||
if (command === 'chain' && commandArgs.length === 0) {
|
if (command === 'chain' && commandArgs.length === 0) {
|
||||||
const stdin = await Bun.stdin.text();
|
const stdin = await Bun.stdin.text();
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const META_COMMANDS = new Set([
|
|||||||
'chain', 'diff',
|
'chain', 'diff',
|
||||||
'url', 'snapshot',
|
'url', 'snapshot',
|
||||||
'handoff', 'resume',
|
'handoff', 'resume',
|
||||||
|
'connect', 'disconnect', 'focus',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
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
|
||||||
'handoff': { category: 'Server', description: 'Open visible Chrome at current page for user takeover', usage: 'handoff [message]' },
|
'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' },
|
'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
|
// Load-time validation: descriptions must cover exactly the command sets
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ export async function handleMetaCommand(
|
|||||||
case 'status': {
|
case 'status': {
|
||||||
const page = bm.getPage();
|
const page = bm.getPage();
|
||||||
const tabs = bm.getTabCount();
|
const tabs = bm.getTabCount();
|
||||||
|
const mode = bm.getConnectionMode();
|
||||||
return [
|
return [
|
||||||
`Status: healthy`,
|
`Status: healthy`,
|
||||||
|
`Mode: ${mode}`,
|
||||||
`URL: ${page.url()}`,
|
`URL: ${page.url()}`,
|
||||||
`Tabs: ${tabs}`,
|
`Tabs: ${tabs}`,
|
||||||
`PID: ${process.pid}`,
|
`PID: ${process.pid}`,
|
||||||
@@ -263,6 +265,69 @@ export async function handleMetaCommand(
|
|||||||
return `RESUMED\n${snapshot}`;
|
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:
|
default:
|
||||||
throw new Error(`Unknown meta command: ${command}`);
|
throw new Error(`Unknown meta command: ${command}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user