opengstack 0.13.7 → 0.13.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/bin/opengstack.js +35 -90
  2. package/package.json +2 -3
  3. package/scripts/install-skills.js +47 -58
  4. package/skills/browse/bin/find-browse +21 -0
  5. package/skills/browse/bin/remote-slug +14 -0
  6. package/skills/browse/scripts/build-node-server.sh +48 -0
  7. package/skills/browse/src/activity.ts +208 -0
  8. package/skills/browse/src/browser-manager.ts +959 -0
  9. package/skills/browse/src/buffers.ts +137 -0
  10. package/skills/browse/src/bun-polyfill.cjs +109 -0
  11. package/skills/browse/src/cli.ts +678 -0
  12. package/skills/browse/src/commands.ts +128 -0
  13. package/skills/browse/src/config.ts +150 -0
  14. package/skills/browse/src/cookie-import-browser.ts +625 -0
  15. package/skills/browse/src/cookie-picker-routes.ts +230 -0
  16. package/skills/browse/src/cookie-picker-ui.ts +688 -0
  17. package/skills/browse/src/find-browse.ts +61 -0
  18. package/skills/browse/src/meta-commands.ts +550 -0
  19. package/skills/browse/src/platform.ts +17 -0
  20. package/skills/browse/src/read-commands.ts +358 -0
  21. package/skills/browse/src/server.ts +1192 -0
  22. package/skills/browse/src/sidebar-agent.ts +280 -0
  23. package/skills/browse/src/sidebar-utils.ts +21 -0
  24. package/skills/browse/src/snapshot.ts +407 -0
  25. package/skills/browse/src/url-validation.ts +95 -0
  26. package/skills/browse/src/write-commands.ts +364 -0
  27. package/skills/browse/test/activity.test.ts +120 -0
  28. package/skills/browse/test/adversarial-security.test.ts +32 -0
  29. package/skills/browse/test/browser-manager-unit.test.ts +17 -0
  30. package/skills/browse/test/bun-polyfill.test.ts +72 -0
  31. package/skills/browse/test/commands.test.ts +2075 -0
  32. package/skills/browse/test/compare-board.test.ts +342 -0
  33. package/skills/browse/test/config.test.ts +316 -0
  34. package/skills/browse/test/cookie-import-browser.test.ts +519 -0
  35. package/skills/browse/test/cookie-picker-routes.test.ts +260 -0
  36. package/skills/browse/test/file-drop.test.ts +271 -0
  37. package/skills/browse/test/find-browse.test.ts +50 -0
  38. package/skills/browse/test/findport.test.ts +191 -0
  39. package/skills/browse/test/fixtures/basic.html +33 -0
  40. package/skills/browse/test/fixtures/cursor-interactive.html +22 -0
  41. package/skills/browse/test/fixtures/dialog.html +15 -0
  42. package/skills/browse/test/fixtures/empty.html +2 -0
  43. package/skills/browse/test/fixtures/forms.html +55 -0
  44. package/skills/browse/test/fixtures/iframe.html +30 -0
  45. package/skills/browse/test/fixtures/network-idle.html +30 -0
  46. package/skills/browse/test/fixtures/qa-eval-checkout.html +108 -0
  47. package/skills/browse/test/fixtures/qa-eval-spa.html +98 -0
  48. package/skills/browse/test/fixtures/qa-eval.html +51 -0
  49. package/skills/browse/test/fixtures/responsive.html +49 -0
  50. package/skills/browse/test/fixtures/snapshot.html +55 -0
  51. package/skills/browse/test/fixtures/spa.html +24 -0
  52. package/skills/browse/test/fixtures/states.html +17 -0
  53. package/skills/browse/test/fixtures/upload.html +25 -0
  54. package/skills/browse/test/gstack-config.test.ts +138 -0
  55. package/skills/browse/test/gstack-update-check.test.ts +514 -0
  56. package/skills/browse/test/handoff.test.ts +235 -0
  57. package/skills/browse/test/path-validation.test.ts +91 -0
  58. package/skills/browse/test/platform.test.ts +37 -0
  59. package/skills/browse/test/server-auth.test.ts +65 -0
  60. package/skills/browse/test/sidebar-agent-roundtrip.test.ts +226 -0
  61. package/skills/browse/test/sidebar-agent.test.ts +199 -0
  62. package/skills/browse/test/sidebar-integration.test.ts +320 -0
  63. package/skills/browse/test/sidebar-unit.test.ts +96 -0
  64. package/skills/browse/test/snapshot.test.ts +467 -0
  65. package/skills/browse/test/state-ttl.test.ts +35 -0
  66. package/skills/browse/test/test-server.ts +57 -0
  67. package/skills/browse/test/url-validation.test.ts +72 -0
  68. package/skills/browse/test/watch.test.ts +129 -0
  69. package/skills/careful/bin/check-careful.sh +112 -0
  70. package/skills/cso/ACKNOWLEDGEMENTS.md +14 -0
  71. package/skills/freeze/bin/check-freeze.sh +79 -0
  72. package/skills/qa/references/issue-taxonomy.md +85 -0
  73. package/skills/qa/templates/qa-report-template.md +126 -0
  74. package/skills/review/TODOS-format.md +62 -0
  75. package/skills/review/checklist.md +220 -0
  76. package/skills/review/design-checklist.md +132 -0
  77. package/skills/review/greptile-triage.md +220 -0
  78. /package/{autoplan → skills/autoplan}/SKILL.md +0 -0
  79. /package/{autoplan → skills/autoplan}/SKILL.md.tmpl +0 -0
  80. /package/{benchmark → skills/benchmark}/SKILL.md +0 -0
  81. /package/{benchmark → skills/benchmark}/SKILL.md.tmpl +0 -0
  82. /package/{browse → skills/browse}/SKILL.md +0 -0
  83. /package/{browse → skills/browse}/SKILL.md.tmpl +0 -0
  84. /package/{canary → skills/canary}/SKILL.md +0 -0
  85. /package/{canary → skills/canary}/SKILL.md.tmpl +0 -0
  86. /package/{careful → skills/careful}/SKILL.md +0 -0
  87. /package/{careful → skills/careful}/SKILL.md.tmpl +0 -0
  88. /package/{codex → skills/codex}/SKILL.md +0 -0
  89. /package/{codex → skills/codex}/SKILL.md.tmpl +0 -0
  90. /package/{connect-chrome → skills/connect-chrome}/SKILL.md +0 -0
  91. /package/{connect-chrome → skills/connect-chrome}/SKILL.md.tmpl +0 -0
  92. /package/{cso → skills/cso}/SKILL.md +0 -0
  93. /package/{cso → skills/cso}/SKILL.md.tmpl +0 -0
  94. /package/{design-consultation → skills/design-consultation}/SKILL.md +0 -0
  95. /package/{design-consultation → skills/design-consultation}/SKILL.md.tmpl +0 -0
  96. /package/{design-review → skills/design-review}/SKILL.md +0 -0
  97. /package/{design-review → skills/design-review}/SKILL.md.tmpl +0 -0
  98. /package/{design-shotgun → skills/design-shotgun}/SKILL.md +0 -0
  99. /package/{design-shotgun → skills/design-shotgun}/SKILL.md.tmpl +0 -0
  100. /package/{document-release → skills/document-release}/SKILL.md +0 -0
  101. /package/{document-release → skills/document-release}/SKILL.md.tmpl +0 -0
  102. /package/{freeze → skills/freeze}/SKILL.md +0 -0
  103. /package/{freeze → skills/freeze}/SKILL.md.tmpl +0 -0
  104. /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md +0 -0
  105. /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md.tmpl +0 -0
  106. /package/{guard → skills/guard}/SKILL.md +0 -0
  107. /package/{guard → skills/guard}/SKILL.md.tmpl +0 -0
  108. /package/{investigate → skills/investigate}/SKILL.md +0 -0
  109. /package/{investigate → skills/investigate}/SKILL.md.tmpl +0 -0
  110. /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md +0 -0
  111. /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md.tmpl +0 -0
  112. /package/{office-hours → skills/office-hours}/SKILL.md +0 -0
  113. /package/{office-hours → skills/office-hours}/SKILL.md.tmpl +0 -0
  114. /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md +0 -0
  115. /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md.tmpl +0 -0
  116. /package/{plan-design-review → skills/plan-design-review}/SKILL.md +0 -0
  117. /package/{plan-design-review → skills/plan-design-review}/SKILL.md.tmpl +0 -0
  118. /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md +0 -0
  119. /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md.tmpl +0 -0
  120. /package/{qa → skills/qa}/SKILL.md +0 -0
  121. /package/{qa → skills/qa}/SKILL.md.tmpl +0 -0
  122. /package/{qa-only → skills/qa-only}/SKILL.md +0 -0
  123. /package/{qa-only → skills/qa-only}/SKILL.md.tmpl +0 -0
  124. /package/{retro → skills/retro}/SKILL.md +0 -0
  125. /package/{retro → skills/retro}/SKILL.md.tmpl +0 -0
  126. /package/{review → skills/review}/SKILL.md +0 -0
  127. /package/{review → skills/review}/SKILL.md.tmpl +0 -0
  128. /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md +0 -0
  129. /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md.tmpl +0 -0
  130. /package/{setup-deploy → skills/setup-deploy}/SKILL.md +0 -0
  131. /package/{setup-deploy → skills/setup-deploy}/SKILL.md.tmpl +0 -0
  132. /package/{ship → skills/ship}/SKILL.md +0 -0
  133. /package/{ship → skills/ship}/SKILL.md.tmpl +0 -0
  134. /package/{unfreeze → skills/unfreeze}/SKILL.md +0 -0
  135. /package/{unfreeze → skills/unfreeze}/SKILL.md.tmpl +0 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * find-browse — locate the gstack browse binary.
3
+ *
4
+ * Compiled to browse/dist/find-browse (standalone binary, no bun runtime needed).
5
+ * Outputs the absolute path to the browse binary on stdout, or exits 1 if not found.
6
+ */
7
+
8
+ import { existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { homedir } from 'os';
11
+
12
+ // ─── Binary Discovery ───────────────────────────────────────────
13
+
14
+ function getGitRoot(): string | null {
15
+ try {
16
+ const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
17
+ stdout: 'pipe',
18
+ stderr: 'pipe',
19
+ });
20
+ if (proc.exitCode !== 0) return null;
21
+ return proc.stdout.toString().trim();
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export function locateBinary(): string | null {
28
+ const root = getGitRoot();
29
+ const home = homedir();
30
+ const markers = ['.codex', '.agents', '.claude'];
31
+
32
+ // Workspace-local takes priority (for development)
33
+ if (root) {
34
+ for (const m of markers) {
35
+ const local = join(root, m, 'skills', 'gstack', 'browse', 'dist', 'browse');
36
+ if (existsSync(local)) return local;
37
+ }
38
+ }
39
+
40
+ // Global fallback
41
+ for (const m of markers) {
42
+ const global = join(home, m, 'skills', 'gstack', 'browse', 'dist', 'browse');
43
+ if (existsSync(global)) return global;
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ // ─── Main ───────────────────────────────────────────────────────
50
+
51
+ function main() {
52
+ const bin = locateBinary();
53
+ if (!bin) {
54
+ process.stderr.write('ERROR: browse binary not found. Run: cd <skill-dir> && ./setup\n');
55
+ process.exit(1);
56
+ }
57
+
58
+ console.log(bin);
59
+ }
60
+
61
+ main();
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Meta commands — tabs, server control, screenshots, chain, diff, snapshot
3
+ */
4
+
5
+ import type { BrowserManager } from './browser-manager';
6
+ import { handleSnapshot } from './snapshot';
7
+ import { getCleanText } from './read-commands';
8
+ import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
9
+ import { validateNavigationUrl } from './url-validation';
10
+ import * as Diff from 'diff';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { TEMP_DIR, isPathWithin } from './platform';
14
+ import { resolveConfig } from './config';
15
+ import type { Frame } from 'playwright';
16
+
17
+ // Security: Path validation to prevent path traversal attacks
18
+ const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
19
+
20
+ export function validateOutputPath(filePath: string): void {
21
+ const resolved = path.resolve(filePath);
22
+ const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
23
+ if (!isSafe) {
24
+ throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
25
+ }
26
+ }
27
+
28
+ /** Tokenize a pipe segment respecting double-quoted strings. */
29
+ function tokenizePipeSegment(segment: string): string[] {
30
+ const tokens: string[] = [];
31
+ let current = '';
32
+ let inQuote = false;
33
+ for (let i = 0; i < segment.length; i++) {
34
+ const ch = segment[i];
35
+ if (ch === '"') {
36
+ inQuote = !inQuote;
37
+ } else if (ch === ' ' && !inQuote) {
38
+ if (current) { tokens.push(current); current = ''; }
39
+ } else {
40
+ current += ch;
41
+ }
42
+ }
43
+ if (current) tokens.push(current);
44
+ return tokens;
45
+ }
46
+
47
+ export async function handleMetaCommand(
48
+ command: string,
49
+ args: string[],
50
+ bm: BrowserManager,
51
+ shutdown: () => Promise<void> | void
52
+ ): Promise<string> {
53
+ switch (command) {
54
+ // ─── Tabs ──────────────────────────────────────────
55
+ case 'tabs': {
56
+ const tabs = await bm.getTabListWithTitles();
57
+ return tabs.map(t =>
58
+ `${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}`
59
+ ).join('\n');
60
+ }
61
+
62
+ case 'tab': {
63
+ const id = parseInt(args[0], 10);
64
+ if (isNaN(id)) throw new Error('Usage: browse tab <id>');
65
+ bm.switchTab(id);
66
+ return `Switched to tab ${id}`;
67
+ }
68
+
69
+ case 'newtab': {
70
+ const url = args[0];
71
+ const id = await bm.newTab(url);
72
+ return `Opened tab ${id}${url ? ` → ${url}` : ''}`;
73
+ }
74
+
75
+ case 'closetab': {
76
+ const id = args[0] ? parseInt(args[0], 10) : undefined;
77
+ await bm.closeTab(id);
78
+ return `Closed tab${id ? ` ${id}` : ''}`;
79
+ }
80
+
81
+ // ─── Server Control ────────────────────────────────
82
+ case 'status': {
83
+ const page = bm.getPage();
84
+ const tabs = bm.getTabCount();
85
+ const mode = bm.getConnectionMode();
86
+ return [
87
+ `Status: healthy`,
88
+ `Mode: ${mode}`,
89
+ `URL: ${page.url()}`,
90
+ `Tabs: ${tabs}`,
91
+ `PID: ${process.pid}`,
92
+ ].join('\n');
93
+ }
94
+
95
+ case 'url': {
96
+ return bm.getCurrentUrl();
97
+ }
98
+
99
+ case 'stop': {
100
+ await shutdown();
101
+ return 'Server stopped';
102
+ }
103
+
104
+ case 'restart': {
105
+ // Signal that we want a restart — the CLI will detect exit and restart
106
+ console.log('[browse] Restart requested. Exiting for CLI to restart.');
107
+ await shutdown();
108
+ return 'Restarting...';
109
+ }
110
+
111
+ // ─── Visual ────────────────────────────────────────
112
+ case 'screenshot': {
113
+ // Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path
114
+ const page = bm.getPage();
115
+ let outputPath = `${TEMP_DIR}/browse-screenshot.png`;
116
+ let clipRect: { x: number; y: number; width: number; height: number } | undefined;
117
+ let targetSelector: string | undefined;
118
+ let viewportOnly = false;
119
+
120
+ const remaining: string[] = [];
121
+ for (let i = 0; i < args.length; i++) {
122
+ if (args[i] === '--viewport') {
123
+ viewportOnly = true;
124
+ } else if (args[i] === '--clip') {
125
+ const coords = args[++i];
126
+ if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]');
127
+ const parts = coords.split(',').map(Number);
128
+ if (parts.length !== 4 || parts.some(isNaN))
129
+ throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers');
130
+ clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
131
+ } else if (args[i].startsWith('--')) {
132
+ throw new Error(`Unknown screenshot flag: ${args[i]}`);
133
+ } else {
134
+ remaining.push(args[i]);
135
+ }
136
+ }
137
+
138
+ // Separate target (selector/@ref) from output path
139
+ for (const arg of remaining) {
140
+ // File paths containing / and ending with an image/pdf extension are never CSS selectors
141
+ const isFilePath = arg.includes('/') && /\.(png|jpe?g|webp|pdf)$/i.test(arg);
142
+ if (isFilePath) {
143
+ outputPath = arg;
144
+ } else if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) {
145
+ targetSelector = arg;
146
+ } else {
147
+ outputPath = arg;
148
+ }
149
+ }
150
+
151
+ validateOutputPath(outputPath);
152
+
153
+ if (clipRect && targetSelector) {
154
+ throw new Error('Cannot use --clip with a selector/ref — choose one');
155
+ }
156
+ if (viewportOnly && clipRect) {
157
+ throw new Error('Cannot use --viewport with --clip — choose one');
158
+ }
159
+
160
+ if (targetSelector) {
161
+ const resolved = await bm.resolveRef(targetSelector);
162
+ const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
163
+ await locator.screenshot({ path: outputPath, timeout: 5000 });
164
+ return `Screenshot saved (element): ${outputPath}`;
165
+ }
166
+
167
+ if (clipRect) {
168
+ await page.screenshot({ path: outputPath, clip: clipRect });
169
+ return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`;
170
+ }
171
+
172
+ await page.screenshot({ path: outputPath, fullPage: !viewportOnly });
173
+ return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`;
174
+ }
175
+
176
+ case 'pdf': {
177
+ const page = bm.getPage();
178
+ const pdfPath = args[0] || `${TEMP_DIR}/browse-page.pdf`;
179
+ validateOutputPath(pdfPath);
180
+ await page.pdf({ path: pdfPath, format: 'A4' });
181
+ return `PDF saved: ${pdfPath}`;
182
+ }
183
+
184
+ case 'responsive': {
185
+ const page = bm.getPage();
186
+ const prefix = args[0] || `${TEMP_DIR}/browse-responsive`;
187
+ validateOutputPath(prefix);
188
+ const viewports = [
189
+ { name: 'mobile', width: 375, height: 812 },
190
+ { name: 'tablet', width: 768, height: 1024 },
191
+ { name: 'desktop', width: 1280, height: 720 },
192
+ ];
193
+ const originalViewport = page.viewportSize();
194
+ const results: string[] = [];
195
+
196
+ for (const vp of viewports) {
197
+ await page.setViewportSize({ width: vp.width, height: vp.height });
198
+ const path = `${prefix}-${vp.name}.png`;
199
+ await page.screenshot({ path, fullPage: true });
200
+ results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
201
+ }
202
+
203
+ // Restore original viewport
204
+ if (originalViewport) {
205
+ await page.setViewportSize(originalViewport);
206
+ }
207
+
208
+ return results.join('\n');
209
+ }
210
+
211
+ // ─── Chain ─────────────────────────────────────────
212
+ case 'chain': {
213
+ // Read JSON array from args[0] (if provided) or expect it was passed as body
214
+ const jsonStr = args[0];
215
+ if (!jsonStr) throw new Error(
216
+ 'Usage: echo \'[["goto","url"],["text"]]\' | browse chain\n' +
217
+ ' or: browse chain \'goto url | click @e5 | snapshot -ic\''
218
+ );
219
+
220
+ let commands: string[][];
221
+ try {
222
+ commands = JSON.parse(jsonStr);
223
+ if (!Array.isArray(commands)) throw new Error('not array');
224
+ } catch {
225
+ // Fallback: pipe-delimited format "goto url | click @e5 | snapshot -ic"
226
+ commands = jsonStr.split(' | ')
227
+ .filter(seg => seg.trim().length > 0)
228
+ .map(seg => tokenizePipeSegment(seg.trim()));
229
+ }
230
+
231
+ const results: string[] = [];
232
+ const { handleReadCommand } = await import('./read-commands');
233
+ const { handleWriteCommand } = await import('./write-commands');
234
+
235
+ let lastWasWrite = false;
236
+ for (const cmd of commands) {
237
+ const [name, ...cmdArgs] = cmd;
238
+ try {
239
+ let result: string;
240
+ if (WRITE_COMMANDS.has(name)) {
241
+ result = await handleWriteCommand(name, cmdArgs, bm);
242
+ lastWasWrite = true;
243
+ } else if (READ_COMMANDS.has(name)) {
244
+ result = await handleReadCommand(name, cmdArgs, bm);
245
+ lastWasWrite = false;
246
+ } else if (META_COMMANDS.has(name)) {
247
+ result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
248
+ lastWasWrite = false;
249
+ } else {
250
+ throw new Error(`Unknown command: ${name}`);
251
+ }
252
+ results.push(`[${name}] ${result}`);
253
+ } catch (err: any) {
254
+ results.push(`[${name}] ERROR: ${err.message}`);
255
+ }
256
+ }
257
+
258
+ // Wait for network to settle after write commands before returning
259
+ if (lastWasWrite) {
260
+ await bm.getPage().waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
261
+ }
262
+
263
+ return results.join('\n\n');
264
+ }
265
+
266
+ // ─── Diff ──────────────────────────────────────────
267
+ case 'diff': {
268
+ const [url1, url2] = args;
269
+ if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
270
+
271
+ const page = bm.getPage();
272
+ await validateNavigationUrl(url1);
273
+ await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
274
+ const text1 = await getCleanText(page);
275
+
276
+ await validateNavigationUrl(url2);
277
+ await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 });
278
+ const text2 = await getCleanText(page);
279
+
280
+ const changes = Diff.diffLines(text1, text2);
281
+ const output: string[] = [`--- ${url1}`, `+++ ${url2}`, ''];
282
+
283
+ for (const part of changes) {
284
+ const prefix = part.added ? '+' : part.removed ? '-' : ' ';
285
+ const lines = part.value.split('\n').filter(l => l.length > 0);
286
+ for (const line of lines) {
287
+ output.push(`${prefix} ${line}`);
288
+ }
289
+ }
290
+
291
+ return output.join('\n');
292
+ }
293
+
294
+ // ─── Snapshot ─────────────────────────────────────
295
+ case 'snapshot': {
296
+ return await handleSnapshot(args, bm);
297
+ }
298
+
299
+ // ─── Handoff ────────────────────────────────────
300
+ case 'handoff': {
301
+ const message = args.join(' ') || 'User takeover requested';
302
+ return await bm.handoff(message);
303
+ }
304
+
305
+ case 'resume': {
306
+ bm.resume();
307
+ // Re-snapshot to capture current page state after human interaction
308
+ const snapshot = await handleSnapshot(['-i'], bm);
309
+ return `RESUMED\n${snapshot}`;
310
+ }
311
+
312
+ // ─── Headed Mode ──────────────────────────────────────
313
+ case 'connect': {
314
+ // connect is handled as a pre-server command in cli.ts
315
+ // If we get here, server is already running — tell the user
316
+ if (bm.getConnectionMode() === 'headed') {
317
+ return 'Already in headed mode with extension.';
318
+ }
319
+ return 'The connect command must be run from the CLI (not sent to a running server). Run: $B connect';
320
+ }
321
+
322
+ case 'disconnect': {
323
+ if (bm.getConnectionMode() !== 'headed') {
324
+ return 'Not in headed mode — nothing to disconnect.';
325
+ }
326
+ // Signal that we want a restart in headless mode
327
+ console.log('[browse] Disconnecting headed browser. Restarting in headless mode.');
328
+ await shutdown();
329
+ return 'Disconnected. Server will restart in headless mode on next command.';
330
+ }
331
+
332
+ case 'focus': {
333
+ if (bm.getConnectionMode() !== 'headed') {
334
+ return 'focus requires headed mode. Run `$B connect` first.';
335
+ }
336
+ try {
337
+ const { execSync } = await import('child_process');
338
+ // Try common Chromium-based browser app names to bring to foreground
339
+ const appNames = ['Comet', 'Google Chrome', 'Arc', 'Brave Browser', 'Microsoft Edge'];
340
+ let activated = false;
341
+ for (const appName of appNames) {
342
+ try {
343
+ execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000 });
344
+ activated = true;
345
+ break;
346
+ } catch {
347
+ // Try next browser
348
+ }
349
+ }
350
+
351
+ if (!activated) {
352
+ return 'Could not bring browser to foreground. macOS only.';
353
+ }
354
+
355
+ // If a ref was passed, scroll it into view
356
+ if (args.length > 0 && args[0].startsWith('@')) {
357
+ try {
358
+ const resolved = await bm.resolveRef(args[0]);
359
+ if ('locator' in resolved) {
360
+ await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
361
+ return `Browser activated. Scrolled ${args[0]} into view.`;
362
+ }
363
+ } catch {
364
+ // Ref not found — still activated the browser
365
+ }
366
+ }
367
+
368
+ return 'Browser window activated.';
369
+ } catch (err: any) {
370
+ return `focus failed: ${err.message}. macOS only.`;
371
+ }
372
+ }
373
+
374
+ // ─── Watch ──────────────────────────────────────────
375
+ case 'watch': {
376
+ if (args[0] === 'stop') {
377
+ if (!bm.isWatching()) return 'Not currently watching.';
378
+ const result = bm.stopWatch();
379
+ const durationSec = Math.round(result.duration / 1000);
380
+ return [
381
+ `WATCH STOPPED (${durationSec}s, ${result.snapshots.length} snapshots)`,
382
+ '',
383
+ 'Last snapshot:',
384
+ result.snapshots.length > 0 ? result.snapshots[result.snapshots.length - 1] : '(none)',
385
+ ].join('\n');
386
+ }
387
+
388
+ if (bm.isWatching()) return 'Already watching. Run `$B watch stop` to stop.';
389
+ if (bm.getConnectionMode() !== 'headed') {
390
+ return 'watch requires headed mode. Run `$B connect` first.';
391
+ }
392
+
393
+ bm.startWatch();
394
+ return 'WATCHING — observing user browsing. Periodic snapshots every 5s.\nRun `$B watch stop` to stop and get summary.';
395
+ }
396
+
397
+ // ─── Inbox ──────────────────────────────────────────
398
+ case 'inbox': {
399
+ const { execSync } = await import('child_process');
400
+ let gitRoot: string;
401
+ try {
402
+ gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
403
+ } catch {
404
+ return 'Not in a git repository — cannot locate inbox.';
405
+ }
406
+
407
+ const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
408
+ if (!fs.existsSync(inboxDir)) return 'Inbox empty.';
409
+
410
+ const files = fs.readdirSync(inboxDir)
411
+ .filter(f => f.endsWith('.json') && !f.startsWith('.'))
412
+ .sort()
413
+ .reverse(); // newest first
414
+
415
+ if (files.length === 0) return 'Inbox empty.';
416
+
417
+ const messages: { timestamp: string; url: string; userMessage: string }[] = [];
418
+ for (const file of files) {
419
+ try {
420
+ const data = JSON.parse(fs.readFileSync(path.join(inboxDir, file), 'utf-8'));
421
+ messages.push({
422
+ timestamp: data.timestamp || '',
423
+ url: data.page?.url || 'unknown',
424
+ userMessage: data.userMessage || '',
425
+ });
426
+ } catch {
427
+ // Skip malformed files
428
+ }
429
+ }
430
+
431
+ if (messages.length === 0) return 'Inbox empty.';
432
+
433
+ const lines: string[] = [];
434
+ lines.push(`SIDEBAR INBOX (${messages.length} message${messages.length === 1 ? '' : 's'})`);
435
+ lines.push('────────────────────────────────');
436
+
437
+ for (const msg of messages) {
438
+ const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
439
+ lines.push(`${ts} ${msg.url}`);
440
+ lines.push(` "${msg.userMessage}"`);
441
+ lines.push('');
442
+ }
443
+
444
+ lines.push('────────────────────────────────');
445
+
446
+ // Handle --clear flag
447
+ if (args.includes('--clear')) {
448
+ for (const file of files) {
449
+ try { fs.unlinkSync(path.join(inboxDir, file)); } catch {}
450
+ }
451
+ lines.push(`Cleared ${files.length} message${files.length === 1 ? '' : 's'}.`);
452
+ }
453
+
454
+ return lines.join('\n');
455
+ }
456
+
457
+ // ─── State ────────────────────────────────────────
458
+ case 'state': {
459
+ const [action, name] = args;
460
+ if (!action || !name) throw new Error('Usage: state save|load <name>');
461
+
462
+ // Sanitize name: alphanumeric + hyphens + underscores only
463
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
464
+ throw new Error('State name must be alphanumeric (a-z, 0-9, _, -)');
465
+ }
466
+
467
+ const config = resolveConfig();
468
+ const stateDir = path.join(config.stateDir, 'browse-states');
469
+ fs.mkdirSync(stateDir, { recursive: true });
470
+ const statePath = path.join(stateDir, `${name}.json`);
471
+
472
+ if (action === 'save') {
473
+ const state = await bm.saveState();
474
+ // V1: cookies + URLs only (not localStorage — breaks on load-before-navigate)
475
+ const saveData = {
476
+ version: 1,
477
+ savedAt: new Date().toISOString(),
478
+ cookies: state.cookies,
479
+ pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })),
480
+ };
481
+ fs.writeFileSync(statePath, JSON.stringify(saveData, null, 2), { mode: 0o600 });
482
+ return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages)\n⚠️ Cookies stored in plaintext. Delete when no longer needed.`;
483
+ }
484
+
485
+ if (action === 'load') {
486
+ if (!fs.existsSync(statePath)) throw new Error(`State not found: ${statePath}`);
487
+ const data = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
488
+ if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
489
+ throw new Error('Invalid state file: expected cookies and pages arrays');
490
+ }
491
+ // Warn on state files older than 7 days
492
+ if (data.savedAt) {
493
+ const ageMs = Date.now() - new Date(data.savedAt).getTime();
494
+ const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
495
+ if (ageMs > SEVEN_DAYS) {
496
+ console.warn(`[browse] Warning: State file is ${Math.round(ageMs / 86400000)} days old. Consider re-saving.`);
497
+ }
498
+ }
499
+ // Close existing pages, then restore (replace, not merge)
500
+ bm.setFrame(null);
501
+ await bm.closeAllPages();
502
+ await bm.restoreState({
503
+ cookies: data.cookies,
504
+ pages: data.pages.map((p: any) => ({ ...p, storage: null })),
505
+ });
506
+ return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`;
507
+ }
508
+
509
+ throw new Error('Usage: state save|load <name>');
510
+ }
511
+
512
+ // ─── Frame ───────────────────────────────────────
513
+ case 'frame': {
514
+ const target = args[0];
515
+ if (!target) throw new Error('Usage: frame <selector|@ref|--name name|--url pattern|main>');
516
+
517
+ if (target === 'main') {
518
+ bm.setFrame(null);
519
+ bm.clearRefs();
520
+ return 'Switched to main frame';
521
+ }
522
+
523
+ const page = bm.getPage();
524
+ let frame: Frame | null = null;
525
+
526
+ if (target === '--name') {
527
+ if (!args[1]) throw new Error('Usage: frame --name <name>');
528
+ frame = page.frame({ name: args[1] });
529
+ } else if (target === '--url') {
530
+ if (!args[1]) throw new Error('Usage: frame --url <pattern>');
531
+ frame = page.frame({ url: new RegExp(args[1]) });
532
+ } else {
533
+ // CSS selector or @ref for the iframe element
534
+ const resolved = await bm.resolveRef(target);
535
+ const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
536
+ const elementHandle = await locator.elementHandle({ timeout: 5000 });
537
+ frame = await elementHandle?.contentFrame() ?? null;
538
+ await elementHandle?.dispose();
539
+ }
540
+
541
+ if (!frame) throw new Error(`Frame not found: ${target}`);
542
+ bm.setFrame(frame);
543
+ bm.clearRefs();
544
+ return `Switched to frame: ${frame.url()}`;
545
+ }
546
+
547
+ default:
548
+ throw new Error(`Unknown meta command: ${command}`);
549
+ }
550
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Cross-platform constants for gstack browse.
3
+ *
4
+ * On macOS/Linux: TEMP_DIR = '/tmp', path.sep = '/' — identical to hardcoded values.
5
+ * On Windows: TEMP_DIR = os.tmpdir(), path.sep = '\\' — correct Windows behavior.
6
+ */
7
+
8
+ import * as os from 'os';
9
+ import * as path from 'path';
10
+
11
+ export const IS_WINDOWS = process.platform === 'win32';
12
+ export const TEMP_DIR = IS_WINDOWS ? os.tmpdir() : '/tmp';
13
+
14
+ /** Check if resolvedPath is within dir, using platform-aware separators. */
15
+ export function isPathWithin(resolvedPath: string, dir: string): boolean {
16
+ return resolvedPath === dir || resolvedPath.startsWith(dir + path.sep);
17
+ }