From e38780b49b403d99438a461cb75e020346072dec Mon Sep 17 00:00:00 2001 From: Garry-TI Date: Fri, 13 Mar 2026 14:17:02 -0400 Subject: [PATCH 1/2] feat: add Windows 11 support for gstack Add cross-platform support so gstack runs on Windows 11 in addition to macOS and Linux. Key changes: - Add setup.ps1 (Windows equivalent of ./setup) using directory junctions - Add browse/build.ts for cross-platform binary compilation - Add browse/src/paths.ts for platform-aware path resolution - Add browse/bin/find-browse.ps1 for Windows binary discovery - Update all browse/src/*.ts to use platform-aware paths and commands - Update README with Windows install instructions (pointing to Garry-TI fork until merged upstream) - Update SKILL.md files with Windows-specific notes - Update package.json build script for cross-platform support Co-Authored-By: Claude Opus 4.6 --- README.md | 27 ++++++--- SKILL.md | 14 +++-- browse/SKILL.md | 2 + browse/bin/find-browse.ps1 | 18 ++++++ browse/build.ts | 8 +++ browse/src/browser-manager.ts | 3 +- browse/src/cli.ts | 7 ++- browse/src/cookie-import-browser.ts | 11 +++- browse/src/meta-commands.ts | 15 ++--- browse/src/paths.ts | 33 ++++++++++ browse/src/read-commands.ts | 9 +-- browse/src/server.ts | 9 +-- browse/src/snapshot.ts | 9 ++- browse/src/write-commands.ts | 9 ++- package.json | 2 +- qa/SKILL.md | 7 ++- setup-browser-cookies/SKILL.md | 14 ++++- setup.ps1 | 94 +++++++++++++++++++++++++++++ 18 files changed, 241 insertions(+), 50 deletions(-) create mode 100644 browse/bin/find-browse.ps1 create mode 100644 browse/build.ts create mode 100644 browse/src/paths.ts create mode 100644 setup.ps1 diff --git a/README.md b/README.md index 6bb7d5c..4b56c5b 100644 --- a/README.md +++ b/README.md @@ -97,30 +97,42 @@ 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. ### Step 1: Install on your machine Open Claude Code and paste this. Claude will do the rest. +**macOS / Linux:** + > 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 (requires [Garry-TI/gstack](https://github.com/Garry-TI/gstack) fork — Windows support has not yet been merged upstream):** + +> 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 ` to import cookies from a JSON file. +- Bun on Windows: install via `powershell -c "irm bun.sh/install.ps1 | iex"`. + --- ``` @@ -506,7 +518,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+. @@ -515,7 +527,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 diff --git a/SKILL.md b/SKILL.md index d657a20..38452a8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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 @@ -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 && ./setup` -3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash` +2. On Windows: `cd && powershell -File setup.ps1` + On macOS/Linux: `cd && ./setup` +3. If `bun` is not installed: visit https://bun.sh/docs/installation ## IMPORTANT @@ -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 diff --git a/browse/SKILL.md b/browse/SKILL.md index 99c979c..8deaf38 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -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 diff --git a/browse/bin/find-browse.ps1 b/browse/bin/find-browse.ps1 new file mode 100644 index 0000000..ab9cfb0 --- /dev/null +++ b/browse/bin/find-browse.ps1 @@ -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 && powershell -File setup.ps1' +exit 1 diff --git a/browse/build.ts b/browse/build.ts new file mode 100644 index 0000000..0fa6622 --- /dev/null +++ b/browse/build.ts @@ -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}`; diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f6726e1..3ddd3c9 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -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; @@ -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); }); diff --git a/browse/src/cli.ts b/browse/src/cli.ts index dae76fb..d3d462f 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -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( @@ -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; @@ -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(); diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 29d9db3..c769cbf 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -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 ────────────────────────────────────────────────────── @@ -104,6 +105,7 @@ const keyCache = new Map(); * 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'); @@ -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) @@ -274,6 +276,13 @@ function openDbFromCopy(dbPath: string, browserName: string): Database { // ─── Internal: Keychain Access (async, 10s timeout) ───────────── async function getDerivedKey(browser: BrowserInfo): Promise { + if (process.platform !== 'darwin') { + throw new CookieImportError( + 'Browser cookie import is only supported on macOS. Use "cookie-import " to import cookies from a JSON file.', + 'unsupported_platform', + ); + } + const cached = keyCache.get(browser.keychainService); if (cached) return cached; diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 8d3f9eb..c0c85d2 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -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(', ')}`); } } @@ -107,7 +104,7 @@ 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}`; @@ -115,7 +112,7 @@ export async function handleMetaCommand( 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}`; @@ -123,7 +120,7 @@ export async function handleMetaCommand( 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 }, diff --git a/browse/src/paths.ts b/browse/src/paths.ts new file mode 100644 index 0000000..8517375 --- /dev/null +++ b/browse/src/paths.ts @@ -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']; +} diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index 31d1018..71f4c72 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -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); diff --git a/browse/src/server.ts b/browse/src/server.ts index 0825b17..a009316 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -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(); @@ -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 { @@ -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; diff --git a/browse/src/snapshot.ts b/browse/src/snapshot.ts index b0c7b80..ba0b84c 100644 --- a/browse/src/snapshot.ts +++ b/browse/src/snapshot.ts @@ -20,6 +20,7 @@ import type { Page, Locator } from 'playwright'; import type { BrowserManager } from './browser-manager'; import * as Diff from 'diff'; +import { tempPath, isPathSafe, safeDirs } from './paths'; // Roles considered "interactive" for the -i flag const INTERACTIVE_ROLES = new Set([ @@ -308,12 +309,10 @@ export async function handleSnapshot( // ─── Annotated screenshot (-a) ──────────────────────────── if (opts.annotate) { - const screenshotPath = opts.outputPath || '/tmp/browse-annotated.png'; + const screenshotPath = opts.outputPath || tempPath('browse-annotated.png'); // Validate output path (consistent with screenshot/pdf/responsive) - const resolvedPath = require('path').resolve(screenshotPath); - const safeDirs = ['/tmp', process.cwd()]; - if (!safeDirs.some((dir: string) => resolvedPath === dir || resolvedPath.startsWith(dir + '/'))) { - throw new Error(`Path must be within: ${safeDirs.join(', ')}`); + if (!isPathSafe(screenshotPath)) { + throw new Error(`Path must be within: ${safeDirs().join(', ')}`); } try { // Inject overlay divs at each ref's bounding box diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 08c9425..014cb55 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -9,6 +9,7 @@ import type { BrowserManager } from './browser-manager'; import { findInstalledBrowsers, importCookies } from './cookie-import-browser'; import * as fs from 'fs'; import * as path from 'path'; +import { safeDirs, isPathSafe, openArgs } from './paths'; export async function handleWriteCommand( command: string, @@ -238,10 +239,8 @@ export async function handleWriteCommand( if (!filePath) throw new Error('Usage: browse cookie-import '); // Path validation — prevent reading arbitrary files if (path.isAbsolute(filePath)) { - const safeDirs = ['/tmp', process.cwd()]; - const resolved = path.resolve(filePath); - if (!safeDirs.some(dir => resolved === dir || resolved.startsWith(dir + '/'))) { - throw new Error(`Path must be within: ${safeDirs.join(', ')}`); + if (!isPathSafe(filePath)) { + throw new Error(`Path must be within: ${safeDirs().join(', ')}`); } } if (path.normalize(filePath).includes('..')) { @@ -298,7 +297,7 @@ export async function handleWriteCommand( const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`; try { - Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' }); + Bun.spawn([...openArgs(), pickerUrl], { stdout: 'ignore', stderr: 'ignore' }); } catch { // open may fail silently — URL is in the message below } diff --git a/package.json b/package.json index bc617a5..fcaaaec 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "browse": "./browse/dist/browse" }, "scripts": { - "build": "bun build --compile browse/src/cli.ts --outfile browse/dist/browse", + "build": "bun run browse/build.ts", "dev": "bun run browse/src/cli.ts", "server": "bun run browse/src/server.ts", "test": "bun test", diff --git a/qa/SKILL.md b/qa/SKILL.md index 9da05fa..4171ac1 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -31,7 +31,12 @@ You are a QA engineer. Test web applications like a real user — click everythi **Find the browse binary:** ```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 [ -z "$B" ]; then echo "ERROR: browse binary not found" exit 1 diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index 28cc778..6a809ed 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -26,7 +26,12 @@ Import logged-in sessions from your real Chromium browser into the headless brow ### 1. Find the browse binary ```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 @@ -36,8 +41,11 @@ 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 && ./setup` -3. If `bun` is not installed: `curl -fsSL https://bun.sh/install | bash` +2. On Windows: `cd && powershell -File setup.ps1` + On macOS/Linux: `cd && ./setup` +3. If `bun` is not installed: visit https://bun.sh/docs/installation + +**Note:** Browser cookie import (`cookie-import-browser`) is currently macOS-only. On Windows, use `cookie-import ` to import cookies from a JSON file. ### 2. Open the cookie picker diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..b602229 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,94 @@ +# gstack setup (Windows) — build browser binary + register all skills with Claude Code +$ErrorActionPreference = 'Stop' + +$GstackDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SkillsDir = Split-Path -Parent $GstackDir +$BrowseBin = Join-Path $GstackDir 'browse\dist\browse.exe' + +function Test-PlaywrightBrowser { + try { + Push-Location $GstackDir + bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();' 2>$null + Pop-Location + return $LASTEXITCODE -eq 0 + } catch { + Pop-Location + return $false + } +} + +# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock) +$NeedsBuild = $false +if (-not (Test-Path $BrowseBin)) { + $NeedsBuild = $true +} elseif (Get-ChildItem (Join-Path $GstackDir 'browse\src') -Recurse -File | Where-Object { $_.LastWriteTime -gt (Get-Item $BrowseBin).LastWriteTime } | Select-Object -First 1) { + $NeedsBuild = $true +} elseif ((Get-Item (Join-Path $GstackDir 'package.json')).LastWriteTime -gt (Get-Item $BrowseBin).LastWriteTime) { + $NeedsBuild = $true +} else { + $BunLock = Join-Path $GstackDir 'bun.lock' + if ((Test-Path $BunLock) -and (Get-Item $BunLock).LastWriteTime -gt (Get-Item $BrowseBin).LastWriteTime) { + $NeedsBuild = $true + } +} + +if ($NeedsBuild) { + Write-Host 'Building browse binary...' + Push-Location $GstackDir + bun install + bun run build + Pop-Location +} + +if (-not (Test-Path $BrowseBin)) { + Write-Error "gstack setup failed: browse binary missing at $BrowseBin" + exit 1 +} + +# 2. Ensure Playwright's Chromium is available +if (-not (Test-PlaywrightBrowser)) { + Write-Host 'Installing Playwright Chromium...' + Push-Location $GstackDir + bunx playwright install chromium + Pop-Location +} + +if (-not (Test-PlaywrightBrowser)) { + Write-Error 'gstack setup failed: Playwright Chromium could not be launched' + exit 1 +} + +# 3. Only create skill junctions if we're inside a .claude/skills directory +$SkillsBasename = Split-Path -Leaf $SkillsDir +if ($SkillsBasename -eq 'skills') { + $linked = @() + Get-ChildItem -Directory $GstackDir | ForEach-Object { + $skillDir = $_.FullName + $skillName = $_.Name + if ($skillName -eq 'node_modules') { return } + if (Test-Path (Join-Path $skillDir 'SKILL.md')) { + $target = Join-Path $SkillsDir $skillName + # Create or update junction; skip if a real directory exists + $isJunction = $false + if (Test-Path $target) { + $item = Get-Item $target -Force + $isJunction = $item.Attributes -band [IO.FileAttributes]::ReparsePoint + } + if ($isJunction -or -not (Test-Path $target)) { + if (Test-Path $target) { Remove-Item $target -Force -Recurse } + cmd /c mklink /J "$target" "$skillDir" | Out-Null + $linked += $skillName + } + } + } + + Write-Host 'gstack ready.' + Write-Host " browse: $BrowseBin" + if ($linked.Count -gt 0) { + Write-Host " linked skills: $($linked -join ', ')" + } +} else { + Write-Host 'gstack ready.' + Write-Host " browse: $BrowseBin" + Write-Host ' (skipped skill junctions - not inside .claude/skills/)' +} From b4d5c9f62b3694b18fdf584fd067972b57113487 Mon Sep 17 00:00:00 2001 From: Garry-TI Date: Fri, 13 Mar 2026 14:27:24 -0400 Subject: [PATCH 2/2] docs: clarify Windows install instructions Make it immediately clear that Windows 11 users need to clone from the Garry-TI/gstack fork, with a prominent callout at the top of the Install section and explicit labels on each platform block. Co-Authored-By: Claude Opus 4.6 --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4b56c5b..305abe5 100644 --- a/README.md +++ b/README.md @@ -99,15 +99,17 @@ This is the setup I use. One person, ten parallel agents, each with the right co **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:** +**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 (requires [Garry-TI/gstack](https://github.com/Garry-TI/gstack) fork — Windows support has not yet been merged upstream):** +**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.