Skill Tracer
Skills › gstack › browse › browse-client.ts

browse-client.ts

264 lines · browse/src/browse-client.ts at 3f79d81

1/**
2 * browse-client — canonical SDK that browser-skill scripts import to drive the
3 * gstack daemon over loopback HTTP.
4 *
5 * Distribution model:
6 * This file is the canonical source. Each browser-skill ships a sibling
7 * copy at `<skill>/_lib/browse-client.ts` (Phase 2's generator copies it
8 * alongside every generated skill; Phase 1's bundled `hackernews-frontpage`
9 * reference skill ships a hand-copied version). The skill imports the
10 * sibling via relative path: `import { browse } from './_lib/browse-client'`.
11 *
12 * Why per-skill copies and not a single global SDK: each skill is fully
13 * portable (copy the directory anywhere, it runs), version drift is
14 * impossible (the SDK is frozen at the version the skill was authored
15 * against), no npm publish workflow, no fixed-path tilde imports.
16 *
17 * Auth resolution:
18 * 1. GSTACK_PORT + GSTACK_SKILL_TOKEN env vars (set by `$B skill run` when
19 * spawning the script). The token is a per-spawn scoped capability bound
20 * to read+write commands; it expires when the spawn ends.
21 * 2. State file fallback: read `BROWSE_STATE_FILE` env or `<git-root>/.gstack/browse.json`
22 * and use the `port` + `token` (the daemon root token). This path exists
23 * for developers running a skill directly via `bun run script.ts` outside
24 * the harness — your own authority, not an agent's.
25 *
26 * Trust:
27 * The SDK exposes only the daemon's existing HTTP surface (POST /command).
28 * No new capabilities. The token's scopes (read+write for spawned skills,
29 * full root for standalone debug) determine what actually executes.
30 *
31 * Zero side effects on import. Safe to import from tests or plain scripts.
32 */
34import * as fs from 'fs';
35import * as path from 'path';
36import * as cp from 'child_process';
38export interface BrowseClientOptions {
39 /** Override port. Default: GSTACK_PORT env or state file. */
40 port?: number;
41 /** Override token. Default: GSTACK_SKILL_TOKEN env, then state file root token. */
42 token?: string;
43 /** Tab id to target (every command can scope to a tab). Default: BROWSE_TAB env or undefined (active tab). */
44 tabId?: number;
45 /** Per-request timeout in milliseconds. Default: 30_000. */
46 timeoutMs?: number;
47 /** Override state-file path. Default: BROWSE_STATE_FILE env or <git-root>/.gstack/browse.json. */
48 stateFile?: string;
51interface ResolvedAuth {
52 port: number;
53 token: string;
54 source: 'env' | 'state-file';
57function parseIntegerEnvValue(value: string | undefined): number | undefined {
58 const trimmed = value?.trim();
59 if (!trimmed || !/^-?\d+$/.test(trimmed)) return undefined;
60 const parsed = parseInt(trimmed, 10);
61 return Number.isFinite(parsed) ? parsed : undefined;
64/** Resolve the daemon port + token. Throws a clear error if neither path works. */
65export function resolveBrowseAuth(opts: BrowseClientOptions = {}): ResolvedAuth {
66 if (opts.port !== undefined && opts.token !== undefined) {
67 return { port: opts.port, token: opts.token, source: 'env' };
68 }
70 // 1. Env vars (set by $B skill run when spawning).
71 const envPort = process.env.GSTACK_PORT;
72 const envToken = process.env.GSTACK_SKILL_TOKEN;
73 if (envPort && envToken) {
74 const port = opts.port ?? parseIntegerEnvValue(envPort);
75 if (port !== undefined) {
76 return { port, token: opts.token ?? envToken, source: 'env' };
77 }
78 }
80 // 2. State file fallback (developer running `bun run script.ts` directly).
81 const stateFile = opts.stateFile ?? process.env.BROWSE_STATE_FILE ?? defaultStateFile();
82 if (stateFile && fs.existsSync(stateFile)) {
83 try {
84 const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
85 if (typeof data.port === 'number' && typeof data.token === 'string') {
86 return {
87 port: opts.port ?? data.port,
88 token: opts.token ?? data.token,
89 source: 'state-file',
90 };
91 }
92 } catch {
93 // fall through to error
94 }
95 }
97 throw new Error(
98 'browse-client: cannot find daemon port + token. Either spawn via `$B skill run` ' +
99 '(sets GSTACK_PORT + GSTACK_SKILL_TOKEN) or run from a project with a live daemon ' +
100 '(.gstack/browse.json must exist).'
101 );
104function defaultStateFile(): string | null {
105 try {
106 const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000, windowsHide: true });
107 const root = proc.status === 0 ? proc.stdout.trim() : null;
108 const base = root || process.cwd();
109 return path.join(base, '.gstack', 'browse.json');
110 } catch {
111 return path.join(process.cwd(), '.gstack', 'browse.json');
112 }
115export class BrowseClientError extends Error {
116 constructor(
117 message: string,
118 public readonly status?: number,
119 public readonly body?: string,
120 ) {
121 super(message);
122 this.name = 'BrowseClientError';
123 }
126/**
127 * Thin client over the daemon's POST /command endpoint.
128 *
129 * Convenience methods cover the common cases (goto, click, text, snapshot,
130 * etc.). For anything not exposed as a method, use `command(cmd, args)`.
131 */
132export class BrowseClient {
133 readonly port: number;
134 readonly token: string;
135 readonly tabId?: number;
136 readonly timeoutMs: number;
138 constructor(opts: BrowseClientOptions = {}) {
139 const auth = resolveBrowseAuth(opts);
140 this.port = auth.port;
141 this.token = auth.token;
142 this.tabId = opts.tabId ?? parseIntegerEnvValue(process.env.BROWSE_TAB);
143 this.timeoutMs = opts.timeoutMs ?? 30_000;
144 }
146 // ─── Low-level dispatch ─────────────────────────────────────────
148 /** Send an arbitrary command; returns raw response text. Throws on non-2xx. */
149 async command(cmd: string, args: string[] = []): Promise<string> {
150 const body = JSON.stringify({
151 command: cmd,
152 args,
153 ...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : {}),
154 });
156 let resp: Response;
157 try {
158 resp = await fetch(`http://127.0.0.1:${this.port}/command`, {
159 method: 'POST',
160 headers: {
161 'Content-Type': 'application/json',
162 'Authorization': `Bearer ${this.token}`,
163 },
164 body,
165 signal: AbortSignal.timeout(this.timeoutMs),
166 });
167 } catch (err: any) {
168 if (err.name === 'TimeoutError' || err.name === 'AbortError') {
169 throw new BrowseClientError(`browse-client: command "${cmd}" timed out after ${this.timeoutMs}ms`);
170 }
171 if (err.code === 'ECONNREFUSED') {
172 throw new BrowseClientError(`browse-client: daemon not running on port ${this.port}`);
173 }
174 throw new BrowseClientError(`browse-client: ${err.message ?? err}`);
175 }
177 const text = await resp.text();
178 if (!resp.ok) {
179 let message = `browse-client: command "${cmd}" failed with status ${resp.status}`;
180 try {
181 const parsed = JSON.parse(text);
182 if (parsed.error) message += `: ${parsed.error}`;
183 } catch {
184 if (text) message += `: ${text.slice(0, 200)}`;
185 }
186 throw new BrowseClientError(message, resp.status, text);
187 }
188 return text;
189 }
191 // ─── Navigation ─────────────────────────────────────────────────
193 async goto(url: string): Promise<string> { return this.command('goto', [url]); }
194 async wait(arg: string): Promise<string> { return this.command('wait', [arg]); }
196 // ─── Reading ────────────────────────────────────────────────────
198 async text(selector?: string): Promise<string> {
199 return this.command('text', selector ? [selector] : []);
200 }
201 async html(selector?: string): Promise<string> {
202 return this.command('html', selector ? [selector] : []);
203 }
204 async links(): Promise<string> { return this.command('links'); }
205 async forms(): Promise<string> { return this.command('forms'); }
206 async accessibility(): Promise<string> { return this.command('accessibility'); }
207 async attrs(selector: string): Promise<string> { return this.command('attrs', [selector]); }
208 async media(...flags: string[]): Promise<string> { return this.command('media', flags); }
209 async data(...flags: string[]): Promise<string> { return this.command('data', flags); }
211 // ─── Interaction ────────────────────────────────────────────────
213 async click(selector: string): Promise<string> { return this.command('click', [selector]); }
214 async fill(selector: string, value: string): Promise<string> { return this.command('fill', [selector, value]); }
215 async select(selector: string, value: string): Promise<string> { return this.command('select', [selector, value]); }
216 async hover(selector: string): Promise<string> { return this.command('hover', [selector]); }
217 async type(text: string): Promise<string> { return this.command('type', [text]); }
218 async press(key: string): Promise<string> { return this.command('press', [key]); }
219 async scroll(selector?: string): Promise<string> {
220 return this.command('scroll', selector ? [selector] : []);
221 }
223 // ─── Snapshot + screenshot ──────────────────────────────────────
225 /** Snapshot returns the ARIA tree. Pass flags like '-i' (interactive only), '-c' (compact). */
226 async snapshot(...flags: string[]): Promise<string> { return this.command('snapshot', flags); }
227 async screenshot(...args: string[]): Promise<string> { return this.command('screenshot', args); }
230/**
231 * Default singleton. Lazily resolves auth on first method call so a script can
232 * import `browse` and immediately call `await browse.goto(...)` without
233 * threading through a constructor.
234 */
235class LazyBrowseClient {
236 private inner: BrowseClient | null = null;
237 private get(): BrowseClient {
238 if (!this.inner) this.inner = new BrowseClient();
239 return this.inner;
240 }
241 // Mirror the BrowseClient surface; each method delegates to a freshly resolved instance.
242 command(cmd: string, args: string[] = []) { return this.get().command(cmd, args); }
243 goto(url: string) { return this.get().goto(url); }
244 wait(arg: string) { return this.get().wait(arg); }
245 text(selector?: string) { return this.get().text(selector); }
246 html(selector?: string) { return this.get().html(selector); }
247 links() { return this.get().links(); }
248 forms() { return this.get().forms(); }
249 accessibility() { return this.get().accessibility(); }
250 attrs(selector: string) { return this.get().attrs(selector); }
251 media(...flags: string[]) { return this.get().media(...flags); }
252 data(...flags: string[]) { return this.get().data(...flags); }
253 click(selector: string) { return this.get().click(selector); }
254 fill(selector: string, value: string) { return this.get().fill(selector, value); }
255 select(selector: string, value: string) { return this.get().select(selector, value); }
256 hover(selector: string) { return this.get().hover(selector); }
257 type(text: string) { return this.get().type(text); }
258 press(key: string) { return this.get().press(key); }
259 scroll(selector?: string) { return this.get().scroll(selector); }
260 snapshot(...flags: string[]) { return this.get().snapshot(...flags); }
261 screenshot(...args: string[]) { return this.get().screenshot(...args); }
264export const browse = new LazyBrowseClient();