Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,30 +97,44 @@ This is the setup I use. One person, ten parallel agents, each with the right co

## Install

**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+. `/browse` compiles a native binary — works on macOS and Linux (x64 and arm64).
**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+. `/browse` compiles a native binary — works on macOS, Linux (x64 and arm64), and Windows 11.

> **Windows 11 users:** Windows support is available from the [Garry-TI/gstack](https://github.com/Garry-TI/gstack) fork. It has not yet been merged into the original repo. Use the Windows instructions below — they clone from the correct repo.

### Step 1: Install on your machine

Open Claude Code and paste this. Claude will do the rest.
Open Claude Code and paste the block for your platform. Claude will do the rest.

**macOS / Linux** — clones from the original repo:

> Install gstack: run `git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup` then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /plan-ceo-review, /plan-eng-review, /review, /ship, /browse, /qa, /setup-browser-cookies, /retro. Then ask the user if they also want to add gstack to the current project so teammates get it.

**Windows 11** — clones from the [Garry-TI fork](https://github.com/Garry-TI/gstack) which includes Windows support:

> Install gstack: run `git clone https://github.com/Garry-TI/gstack.git "$env:USERPROFILE/.claude/skills/gstack"` then `cd "$env:USERPROFILE/.claude/skills/gstack"` then `powershell -File setup.ps1`. Then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /plan-ceo-review, /plan-eng-review, /review, /ship, /browse, /qa, /setup-browser-cookies, /retro. Then ask the user if they also want to add gstack to the current project so teammates get it.

### Step 2: Add to your repo so teammates get it (optional)

> Add gstack to this project: run `cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup` then add a "gstack" section to this project's CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, lists the available skills: /plan-ceo-review, /plan-eng-review, /review, /ship, /browse, /qa, /setup-browser-cookies, /retro, and tells Claude that if gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` to build the binary and register skills.
> Add gstack to this project: run `cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup` then add a "gstack" section to this project's CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, lists the available skills: /plan-ceo-review, /plan-eng-review, /review, /ship, /browse, /qa, /setup-browser-cookies, /retro, and tells Claude that if gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` (or `powershell -File setup.ps1` on Windows) to build the binary and register skills.

Real files get committed to your repo (not a submodule), so `git clone` just works. The binary and node\_modules are gitignored — teammates just need to run `cd .claude/skills/gstack && ./setup` once to build (or `/browse` handles it automatically on first use).
Real files get committed to your repo (not a submodule), so `git clone` just works. The binary and node\_modules are gitignored — teammates just need to run `cd .claude/skills/gstack && ./setup` (or `powershell -File setup.ps1` on Windows) once to build (or `/browse` handles it automatically on first use).

### What gets installed

- Skill files (Markdown prompts) in `~/.claude/skills/gstack/` (or `.claude/skills/gstack/` for project installs)
- Symlinks at `~/.claude/skills/browse`, `~/.claude/skills/qa`, `~/.claude/skills/review`, etc. pointing into the gstack directory
- Browser binary at `browse/dist/browse` (~58MB, gitignored)
- Symlinks (macOS/Linux) or directory junctions (Windows) at `~/.claude/skills/browse`, `~/.claude/skills/qa`, `~/.claude/skills/review`, etc. pointing into the gstack directory
- Browser binary at `browse/dist/browse` (macOS/Linux) or `browse/dist/browse.exe` (Windows) — ~58MB, gitignored
- `node_modules/` (gitignored)
- `/retro` saves JSON snapshots to `.context/retros/` in your project for trend tracking

Everything lives inside `.claude/`. Nothing touches your PATH or runs in the background.

### Windows notes

- The `setup.ps1` script is the Windows equivalent of `./setup`. It uses directory junctions (no admin required) instead of symlinks.
- `/setup-browser-cookies` (cookie import from browsers) is macOS-only. On Windows, use `cookie-import <json-file>` to import cookies from a JSON file.
- Bun on Windows: install via `powershell -c "irm bun.sh/install.ps1 | iex"`.

---

```
Expand Down Expand Up @@ -506,7 +520,7 @@ It saves a JSON snapshot to `.context/retros/` so the next run can show trends.
## Troubleshooting

**Skill not showing up in Claude Code?**
Run `cd ~/.claude/skills/gstack && ./setup` (or `cd .claude/skills/gstack && ./setup` for project installs). This rebuilds symlinks so Claude can discover the skills.
Run `cd ~/.claude/skills/gstack && ./setup` (or `cd .claude/skills/gstack && ./setup` for project installs). On Windows: `cd ~\.claude\skills\gstack && powershell -File setup.ps1`. This rebuilds symlinks/junctions so Claude can discover the skills.

**`/browse` fails or binary not found?**
Run `cd ~/.claude/skills/gstack && bun install && bun run build`. This compiles the browser binary. Requires Bun v1.0+.
Expand All @@ -515,7 +529,8 @@ Run `cd ~/.claude/skills/gstack && bun install && bun run build`. This compiles
Re-copy from global: `for s in browse plan-ceo-review plan-eng-review review ship retro qa setup-browser-cookies; do rm -f .claude/skills/$s; done && rm -rf .claude/skills/gstack && cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup`

**`bun` not installed?**
Install it: `curl -fsSL https://bun.sh/install | bash`
macOS/Linux: `curl -fsSL https://bun.sh/install | bash`
Windows: `powershell -c "irm bun.sh/install.ps1 | iex"`

## Upgrading

Expand Down
14 changes: 10 additions & 4 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ Auto-shuts down after 30 min idle. State persists between calls (cookies, tabs,
## SETUP (run this check BEFORE any browse command)

```bash
B=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null)
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then
B=$(find "$USERPROFILE/.claude/skills/gstack/browse/dist" -maxdepth 1 -name "browse.exe" 2>/dev/null | head -1)
[ -z "$B" ] && B=$(find "$(git rev-parse --show-toplevel 2>/dev/null)/.claude/skills/gstack/browse/dist" -maxdepth 1 -name "browse.exe" 2>/dev/null | head -1)
else
B=$(browse/bin/find-browse 2>/dev/null || ~/.claude/skills/gstack/browse/bin/find-browse 2>/dev/null)
fi
if [ -n "$B" ]; then
echo "READY: $B"
else
Expand All @@ -31,8 +36,9 @@ fi

If `NEEDS_SETUP`:
1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait.
2. Run: `cd <SKILL_DIR> && ./setup`
3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash`
2. On Windows: `cd <SKILL_DIR> && powershell -File setup.ps1`
On macOS/Linux: `cd <SKILL_DIR> && ./setup`
3. If `bun` is not installed: visit https://bun.sh/docs/installation

## IMPORTANT

Expand All @@ -46,7 +52,7 @@ If `NEEDS_SETUP`:
### Test a user flow (login, signup, checkout, etc.)

```bash
B=~/.claude/skills/gstack/browse/dist/browse
# Use $B from the SETUP block above

# 1. Go to the page
$B goto https://app.example.com/login
Expand Down
2 changes: 2 additions & 0 deletions browse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ allowed-tools:
Persistent headless Chromium. First call auto-starts (~3s), then ~100ms per command.
State persists between calls (cookies, tabs, login sessions).

**Note:** Commands like `screenshot`, `pdf`, `responsive`, and `snapshot -a` default to the system temp directory when no path is given. On Windows this is `%TEMP%`, on macOS/Linux `/tmp`.

## Core QA Patterns

### 1. Verify a page loads correctly
Expand Down
18 changes: 18 additions & 0 deletions browse/bin/find-browse.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Find the gstack browse binary (Windows). Echoes path and exits 0, or exits 1 if not found.
$Root = git rev-parse --show-toplevel 2>$null

$Candidates = @()
if ($Root) {
$Candidates += Join-Path $Root '.claude\skills\gstack\browse\dist\browse.exe'
}
$Candidates += Join-Path $env:USERPROFILE '.claude\skills\gstack\browse\dist\browse.exe'

foreach ($c in $Candidates) {
if (Test-Path $c) {
Write-Output $c
exit 0
}
}

Write-Error 'ERROR: browse binary not found. Run: cd <skill-dir> && powershell -File setup.ps1'
exit 1
8 changes: 8 additions & 0 deletions browse/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Platform-aware build script — produces browse.exe on Windows, browse on Unix.
*/

import { $ } from 'bun';

const ext = process.platform === 'win32' ? '.exe' : '';
await $`bun build --compile browse/src/cli.ts --outfile browse/dist/browse${ext}`;
3 changes: 2 additions & 1 deletion browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright';
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
import { TEMP_DIR } from './paths';

export class BrowserManager {
private browser: Browser | null = null;
Expand Down Expand Up @@ -47,7 +48,7 @@ export class BrowserManager {
// Chromium crash → exit with clear message
this.browser.on('disconnected', () => {
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
console.error('[browse] Console/network logs flushed to /tmp/browse-*.log');
console.error(`[browse] Console/network logs flushed to ${TEMP_DIR}/browse-*.log`);
process.exit(1);
});

Expand Down
7 changes: 4 additions & 3 deletions browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@

import * as fs from 'fs';
import * as path from 'path';
import { tempPath, homeDir } from './paths';

const PORT_OFFSET = 45600;
const BROWSE_PORT = process.env.CONDUCTOR_PORT
? parseInt(process.env.CONDUCTOR_PORT, 10) - PORT_OFFSET
: parseInt(process.env.BROWSE_PORT || '0', 10);
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
const STATE_FILE = process.env.BROWSE_STATE_FILE || tempPath(`browse-server${INSTANCE_SUFFIX}.json`);
const MAX_START_WAIT = 8000; // 8 seconds to start

export function resolveServerScript(
Expand All @@ -30,7 +31,7 @@ export function resolveServerScript(
}

// Dev mode: cli.ts runs directly from browse/src
if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) {
if (path.isAbsolute(metaDir) && !metaDir.includes('$bunfs')) {
const direct = path.resolve(metaDir, 'server.ts');
if (fs.existsSync(direct)) {
return direct;
Expand All @@ -46,7 +47,7 @@ export function resolveServerScript(
}

// Legacy fallback for user-level installs
return path.resolve(env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts');
return path.resolve(env.HOME || env.USERPROFILE || homeDir(), '.claude/skills/gstack/browse/src/server.ts');
}

const SERVER_SCRIPT = resolveServerScript();
Expand Down
11 changes: 10 additions & 1 deletion browse/src/cookie-import-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { tempPath } from './paths';

// ─── Types ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -104,6 +105,7 @@ const keyCache = new Map<string, Buffer>();
* Find which browsers are installed (have a cookie DB on disk).
*/
export function findInstalledBrowsers(): BrowserInfo[] {
if (process.platform !== 'darwin') return [];
const appSupport = path.join(os.homedir(), 'Library', 'Application Support');
return BROWSER_REGISTRY.filter(b => {
const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies');
Expand Down Expand Up @@ -241,7 +243,7 @@ function openDb(dbPath: string, browserName: string): Database {
}

function openDbFromCopy(dbPath: string, browserName: string): Database {
const tmpPath = `/tmp/browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`;
const tmpPath = tempPath(`browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`);
try {
fs.copyFileSync(dbPath, tmpPath);
// Also copy WAL and SHM if they exist (for consistent reads)
Expand Down Expand Up @@ -274,6 +276,13 @@ function openDbFromCopy(dbPath: string, browserName: string): Database {
// ─── Internal: Keychain Access (async, 10s timeout) ─────────────

async function getDerivedKey(browser: BrowserInfo): Promise<Buffer> {
if (process.platform !== 'darwin') {
throw new CookieImportError(
'Browser cookie import is only supported on macOS. Use "cookie-import <json-file>" to import cookies from a JSON file.',
'unsupported_platform',
);
}

const cached = keyCache.get(browser.keychainService);
if (cached) return cached;

Expand Down
15 changes: 6 additions & 9 deletions browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,12 @@ import { getCleanText } from './read-commands';
import * as Diff from 'diff';
import * as fs from 'fs';
import * as path from 'path';
import { safeDirs, isPathSafe, tempPath } from './paths';

// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];

function validateOutputPath(filePath: string): void {
const resolved = path.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
if (!isPathSafe(filePath)) {
throw new Error(`Path must be within: ${safeDirs().join(', ')}`);
}
}

Expand Down Expand Up @@ -107,23 +104,23 @@ export async function handleMetaCommand(
// ─── Visual ────────────────────────────────────────
case 'screenshot': {
const page = bm.getPage();
const screenshotPath = args[0] || '/tmp/browse-screenshot.png';
const screenshotPath = args[0] || tempPath('browse-screenshot.png');
validateOutputPath(screenshotPath);
await page.screenshot({ path: screenshotPath, fullPage: true });
return `Screenshot saved: ${screenshotPath}`;
}

case 'pdf': {
const page = bm.getPage();
const pdfPath = args[0] || '/tmp/browse-page.pdf';
const pdfPath = args[0] || tempPath('browse-page.pdf');
validateOutputPath(pdfPath);
await page.pdf({ path: pdfPath, format: 'A4' });
return `PDF saved: ${pdfPath}`;
}

case 'responsive': {
const page = bm.getPage();
const prefix = args[0] || '/tmp/browse-responsive';
const prefix = args[0] || tempPath('browse-responsive');
validateOutputPath(prefix);
const viewports = [
{ name: 'mobile', width: 375, height: 812 },
Expand Down
33 changes: 33 additions & 0 deletions browse/src/paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Cross-platform path utilities — centralizes all platform-dependent path logic.
*
* Every source file imports from here instead of hardcoding /tmp/ or path separators.
*/

import * as os from 'os';
import * as path from 'path';

export const TEMP_DIR = os.tmpdir();

export function tempPath(filename: string): string {
return path.join(TEMP_DIR, filename);
}

export function safeDirs(): string[] {
return [TEMP_DIR, process.cwd()];
}

export function isPathSafe(filePath: string, dirs: string[] = safeDirs()): boolean {
const resolved = path.resolve(filePath);
return dirs.some(dir => resolved === dir || resolved.startsWith(dir + path.sep));
}

export function homeDir(): string {
return os.homedir();
}

export function openArgs(): string[] {
if (process.platform === 'win32') return ['cmd', '/c', 'start', ''];
if (process.platform === 'darwin') return ['open'];
return ['xdg-open'];
}
9 changes: 3 additions & 6 deletions browse/src/read-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,13 @@ import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
import type { Page } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { safeDirs, isPathSafe } from './paths';

// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];

function validateReadPath(filePath: string): void {
if (path.isAbsolute(filePath)) {
const resolved = path.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
if (!isSafe) {
throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
if (!isPathSafe(filePath)) {
throw new Error(`Absolute path must be within: ${safeDirs().join(', ')}`);
}
}
const normalized = path.normalize(filePath);
Expand Down
9 changes: 5 additions & 4 deletions browse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { handleCookiePickerRoute } from './cookie-picker-routes';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { tempPath } from './paths';

// ─── Auth (inline) ─────────────────────────────────────────────
const AUTH_TOKEN = crypto.randomUUID();
Expand All @@ -24,7 +25,7 @@ const BROWSE_PORT = process.env.CONDUCTOR_PORT
? parseInt(process.env.CONDUCTOR_PORT, 10) - PORT_OFFSET
: parseInt(process.env.BROWSE_PORT || '0', 10); // 0 = auto-scan
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
const STATE_FILE = process.env.BROWSE_STATE_FILE || tempPath(`browse-server${INSTANCE_SUFFIX}.json`);
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min

function validateAuth(req: Request): boolean {
Expand All @@ -36,9 +37,9 @@ function validateAuth(req: Request): boolean {
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers';
export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry };

const CONSOLE_LOG_PATH = `/tmp/browse-console${INSTANCE_SUFFIX}.log`;
const NETWORK_LOG_PATH = `/tmp/browse-network${INSTANCE_SUFFIX}.log`;
const DIALOG_LOG_PATH = `/tmp/browse-dialog${INSTANCE_SUFFIX}.log`;
const CONSOLE_LOG_PATH = tempPath(`browse-console${INSTANCE_SUFFIX}.log`);
const NETWORK_LOG_PATH = tempPath(`browse-network${INSTANCE_SUFFIX}.log`);
const DIALOG_LOG_PATH = tempPath(`browse-dialog${INSTANCE_SUFFIX}.log`);
let lastConsoleFlushed = 0;
let lastNetworkFlushed = 0;
let lastDialogFlushed = 0;
Expand Down
Loading