veil-browser 0.1.7 → 0.2.0

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.
package/SKILL.md ADDED
@@ -0,0 +1,275 @@
1
+ # veil — OpenClaw Stealth Browser Remote
2
+
3
+ ## What is veil
4
+
5
+ veil is a **headless browser remote control** for OpenClaw. It runs a persistent stealth Chromium browser and exposes it through a clean CLI. You (OpenClaw) are the brain — veil is just the hands.
6
+
7
+ Every command outputs clean JSON: `{ ok: true, ... }` or `{ ok: false, error: "..." }`
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install -g veil-browser
13
+ npx playwright install chromium
14
+ ```
15
+
16
+ ## Core Mental Model
17
+
18
+ You chain veil commands like a human would:
19
+ 1. Open the browser to the right platform (`veil open x`)
20
+ 2. Read the page (`veil snapshot` or `veil read`)
21
+ 3. Act on what you see (`veil click`, `veil type`)
22
+ 4. Verify the result (`veil find`, `veil read`, `veil shot`)
23
+
24
+ ---
25
+
26
+ ## Full Command Reference
27
+
28
+ ### Session Setup (do once)
29
+ ```bash
30
+ veil login x # Opens visible browser → log in → saves cookies
31
+ veil login linkedin
32
+ veil login reddit
33
+ veil sessions # List saved sessions
34
+ ```
35
+
36
+ ### Open a Session
37
+ ```bash
38
+ veil open x # Restore X session, navigate to home feed
39
+ veil open linkedin # Restore LinkedIn session
40
+ veil open <platform> # Any saved platform
41
+ ```
42
+
43
+ ### Navigation
44
+ ```bash
45
+ veil go https://x.com/home # Navigate to URL
46
+ veil go https://x.com --wait load # Wait for full load
47
+ veil url # Get current URL + title
48
+ veil back # Go back
49
+ ```
50
+
51
+ ### Reading the Page
52
+ ```bash
53
+ veil snapshot # DOM/ARIA tree — use this to understand page structure
54
+ veil snapshot --max 4000 # Smaller snapshot for faster processing
55
+ veil read # Full page text (first 5000 chars)
56
+ veil read "[data-testid='tweetText']" # Text of first match
57
+ veil read "[data-testid='tweetText']" --all # All matches as array
58
+ veil read "a" --attr href # Read attribute
59
+ veil find "Sign in" # Check if text exists: { ok: true, found: true }
60
+ veil exists "[data-testid='like']" # Check if selector exists
61
+ ```
62
+
63
+ ### Clicking
64
+ ```bash
65
+ veil click "[data-testid='like']" # Click first match
66
+ veil click "[data-testid='like']" --nth 2 # Click 3rd match (0-indexed)
67
+ veil click "[data-testid='like']" --force # Force click (bypass overlays)
68
+ veil click "[data-testid='reply']" --timeout 8000
69
+ ```
70
+
71
+ ### Typing
72
+ ```bash
73
+ veil type "[data-testid='tweetTextarea_0']" "Hello world"
74
+ veil type "input[name='search']" "AI architecture" --clear # Clear first
75
+ veil type "[data-testid='tweetTextarea_0']" "text" --delay 60 # Slower, more human
76
+ ```
77
+
78
+ ### Keyboard
79
+ ```bash
80
+ veil press Enter
81
+ veil press Tab
82
+ veil press Escape
83
+ veil press ArrowDown
84
+ ```
85
+
86
+ ### Scrolling
87
+ ```bash
88
+ veil scroll down # Scroll 600px down
89
+ veil scroll up # Scroll 600px up
90
+ veil scroll down --amount 1200
91
+ veil scroll top # Jump to top of page
92
+ veil scroll bottom # Jump to bottom
93
+ ```
94
+
95
+ ### Timing
96
+ ```bash
97
+ veil wait 500 # Wait 500ms
98
+ veil wait 2000 # Wait 2 seconds
99
+ veil wait-for "[data-testid='timeline']" # Wait until element appears
100
+ veil wait-for "[data-testid='timeline']" --timeout 15000
101
+ ```
102
+
103
+ ### Screenshots
104
+ ```bash
105
+ veil shot # Screenshot to veil-<timestamp>.png
106
+ veil shot page.png # Custom filename
107
+ veil shot page.png --full # Full page
108
+ veil shot el.png --selector "[data-testid='tweet']" # Screenshot element
109
+ ```
110
+
111
+ ### JavaScript Execution
112
+ ```bash
113
+ veil eval "document.title"
114
+ veil eval "window.scrollY"
115
+ veil eval "document.querySelectorAll('article').length"
116
+ ```
117
+
118
+ ### Close Browser
119
+ ```bash
120
+ veil close # Close current browser
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Platform Selectors
126
+
127
+ ### X (Twitter) — x.com
128
+
129
+ | Element | Selector |
130
+ |---------|----------|
131
+ | Tweet text area | `[data-testid="tweetTextarea_0"]` |
132
+ | Post/Tweet button | `[data-testid="tweetButtonInline"]` |
133
+ | Like button | `[data-testid="like"]` |
134
+ | Unlike (already liked) | `[data-testid="unlike"]` |
135
+ | Reply button | `[data-testid="reply"]` |
136
+ | Retweet button | `[data-testid="retweet"]` |
137
+ | Share/More options | `[aria-label="Share Tweet"]` |
138
+ | A tweet (article) | `article[data-testid="tweet"]` |
139
+ | First tweet | `article[data-testid="tweet"]:first-of-type` |
140
+ | Tweet text content | `[data-testid="tweetText"]` |
141
+ | Feed timeline | `[data-testid="primaryColumn"]` |
142
+ | Search box | `[data-testid="SearchBox_Search_Input"]` |
143
+ | Menu items | `[role="menuitem"]` |
144
+ | Follow button | `[data-testid="follow"]` |
145
+
146
+ ### LinkedIn — linkedin.com
147
+
148
+ | Element | Selector |
149
+ |---------|----------|
150
+ | Like button | `button[aria-label*="Like"]` |
151
+ | Comment button | `button[aria-label*="Comment"]` |
152
+ | Share button | `button[aria-label*="Share"]` |
153
+ | Comment text area | `[contenteditable="true"][role="textbox"]` |
154
+ | Post button | `button.comments-comment-box__submit-button` |
155
+ | Feed posts | `.feed-shared-update-v2` |
156
+
157
+ ### Reddit — reddit.com
158
+
159
+ | Element | Selector |
160
+ |---------|----------|
161
+ | Upvote button | `button[aria-label="upvote"]` |
162
+ | Comment link | `a[data-click-id="comments"]` |
163
+ | Comment box | `[contenteditable="true"]` |
164
+ | Submit comment | `button:has-text("Comment")` |
165
+
166
+ ---
167
+
168
+ ## Common Task Sequences
169
+
170
+ ### Post a Tweet on X
171
+ ```bash
172
+ veil open x
173
+ veil wait-for "[data-testid='primaryColumn']"
174
+ veil click "[data-testid='tweetTextarea_0']"
175
+ veil wait 300
176
+ veil type "[data-testid='tweetTextarea_0']" "Your tweet text here"
177
+ veil wait 500
178
+ veil click "[data-testid='tweetButtonInline']" --force
179
+ veil wait 1500
180
+ veil find "Your tweet text here"
181
+ ```
182
+
183
+ ### Like First 5 Posts on X Feed
184
+ ```bash
185
+ veil open x
186
+ veil wait-for "article[data-testid='tweet']"
187
+ veil click "[data-testid='like']" --nth 0
188
+ veil wait 800
189
+ veil click "[data-testid='like']" --nth 1
190
+ veil wait 800
191
+ veil click "[data-testid='like']" --nth 2
192
+ veil wait 800
193
+ veil click "[data-testid='like']" --nth 3
194
+ veil wait 800
195
+ veil click "[data-testid='like']" --nth 4
196
+ veil wait 800
197
+ ```
198
+
199
+ ### Reply to First Post on X Feed
200
+ ```bash
201
+ veil open x
202
+ veil wait-for "article[data-testid='tweet']"
203
+ veil click "[data-testid='reply']" --nth 0 --force
204
+ veil wait 600
205
+ veil click "[data-testid='tweetTextarea_0']"
206
+ veil wait 200
207
+ veil type "[data-testid='tweetTextarea_0']" "Great post! This is exactly why AI needs structured reasoning."
208
+ veil wait 400
209
+ veil click "[data-testid='tweetButton']" --force
210
+ veil wait 1500
211
+ ```
212
+
213
+ ### Search X for AI Posts
214
+ ```bash
215
+ veil open x
216
+ veil go https://x.com/search?q=AI+architecture&f=live
217
+ veil wait-for "article[data-testid='tweet']"
218
+ veil snapshot --max 3000
219
+ # Now you can see the results and decide which to interact with
220
+ ```
221
+
222
+ ### Read Feed Before Interacting
223
+ ```bash
224
+ veil open x
225
+ veil wait-for "article[data-testid='tweet']"
226
+ veil read "[data-testid='tweetText']" --all
227
+ # Returns JSON array of tweet texts — you can decide which to reply to
228
+ ```
229
+
230
+ ---
231
+
232
+ ## Error Handling
233
+
234
+ Every command returns JSON. Always check `ok`:
235
+
236
+ ```bash
237
+ result=$(veil click "[data-testid='like']")
238
+ # Check: echo $result | jq .ok
239
+ # If false: echo $result | jq .error
240
+ ```
241
+
242
+ **Common errors and fixes:**
243
+
244
+ | Error | Fix |
245
+ |-------|-----|
246
+ | `No browser open` | Run `veil open x` first |
247
+ | `Timeout waiting for selector` | Page not loaded yet — add `veil wait 2000` before |
248
+ | `Element not found` | Use `veil snapshot` to inspect actual DOM, adjust selector |
249
+ | `Session not found` | Run `veil login x` to create session |
250
+ | Click fails (overlay) | Add `--force` flag to `veil click` |
251
+
252
+ **Debug workflow:**
253
+ ```bash
254
+ veil shot debug.png # What does the page actually look like?
255
+ veil snapshot --max 5000 # What's in the DOM?
256
+ veil exists "[selector]" # Does the element even exist?
257
+ ```
258
+
259
+ ---
260
+
261
+ ## Session Persistence
262
+
263
+ - Sessions stored in `~/.veil/sessions/<platform>.json` (encrypted cookies)
264
+ - Browser instance stays open within a single execution chain
265
+ - Each new `veil` command that needs the browser checks for existing session
266
+ - Use `veil close` to explicitly close the browser
267
+
268
+ ---
269
+
270
+ ## Notes
271
+
272
+ - All clicks on X use `--force` by default to bypass overlay interceptors
273
+ - Human-like delays are added automatically between interactions
274
+ - FlareSolverr auto-starts via Docker if Cloudflare challenges are detected
275
+ - veil outputs only JSON to stdout — safe to pipe and parse
package/dist/ai.js CHANGED
@@ -81,30 +81,51 @@ async function getPageSnapshot(page) {
81
81
  }
82
82
  // Call LLM to get action steps
83
83
  async function getActionsFromLLM(instruction, snapshot, pageUrl, llm) {
84
- const systemPrompt = `You are a browser automation assistant. Given a page snapshot (ARIA/DOM tree) and a user instruction, return a JSON array of action steps to complete the task.
84
+ // Load platform-specific guide
85
+ let platformGuide = '';
86
+ if (pageUrl.includes('x.com') || pageUrl.includes('twitter.com')) {
87
+ try {
88
+ const { readFileSync } = await import('fs');
89
+ platformGuide = readFileSync(new URL('../prompts/x-guide.md', import.meta.url), 'utf-8');
90
+ }
91
+ catch { }
92
+ }
93
+ else if (pageUrl.includes('linkedin.com')) {
94
+ try {
95
+ const { readFileSync } = await import('fs');
96
+ platformGuide = readFileSync(new URL('../prompts/linkedin-guide.md', import.meta.url), 'utf-8');
97
+ }
98
+ catch { }
99
+ }
100
+ const systemPrompt = `You are an expert browser automation assistant. Given a page snapshot and a user instruction, return ONLY a valid JSON array of action steps.
101
+
102
+ ${platformGuide ? `\n## Platform-Specific Guide\n${platformGuide}\n` : ''}
85
103
 
86
- Available actions:
87
- - click: { action: "click", selector: "CSS or data-testid selector", description: "..." }
88
- - type: { action: "type", selector: "...", text: "the text to type", description: "..." }
89
- - press: { action: "press", key: "Enter|Tab|Escape|...", description: "..." }
90
- - navigate: { action: "navigate", url: "https://...", description: "..." }
91
- - wait: { action: "wait", ms: 1000, description: "..." }
92
- - scroll: { action: "scroll", direction: "down", description: "..." }
104
+ ## Available Actions
105
+ - click: { action: "click", selector: "CSS or data-testid selector" }
106
+ - type: { action: "type", selector: "...", text: "text to type" }
107
+ - press: { action: "press", key: "Enter|Tab|Escape|..." }
108
+ - navigate: { action: "navigate", url: "https://..." }
109
+ - wait: { action: "wait", ms: 1000 }
110
+ - scroll: { action: "scroll", direction: "down" }
93
111
 
94
- Rules:
95
- - Prefer data-testid selectors when available (most stable)
96
- - For Twitter/X: use [data-testid="tweetTextarea_0"] for tweet box, [data-testid="tweetButtonInline"] for post button
97
- - Return ONLY valid JSON array, no explanation
98
- - Add a wait step after clicks on buttons that trigger UI changes
99
- - For typing in contenteditable areas, click first then type`;
112
+ ## Rules
113
+ 1. Return ONLY valid JSON array, nothing else
114
+ 2. Each action MUST have all required fields
115
+ 3. Never generate navigate actions unless instruction explicitly says to go to a URL
116
+ 4. Add description field to every action
117
+ 5. After clicks that open modals/menus, always add 500ms wait
118
+ 6. Use data-testid selectors when available (most stable)
119
+ 7. For contenteditable areas, click first then type
120
+ 8. Always filter posts by visible content before interacting`;
100
121
  const userPrompt = `Current URL: ${pageUrl}
101
122
 
102
123
  Page snapshot:
103
- ${snapshot.slice(0, 4000)}
124
+ ${snapshot.slice(0, 3500)}
104
125
 
105
126
  Instruction: ${instruction}
106
127
 
107
- Return JSON array of action steps:`;
128
+ Return ONLY a valid JSON array of action steps with no other text:`;
108
129
  let response;
109
130
  if (llm.provider === 'ollama') {
110
131
  const baseUrl = llm.baseUrl ?? 'http://localhost:11434';
package/dist/browser.d.ts CHANGED
@@ -1,12 +1,12 @@
1
- import type { Browser, BrowserContext, Page } from 'playwright';
2
- export interface LaunchOptions {
1
+ import { Browser, BrowserContext, Page } from 'playwright';
2
+ export declare function ensureBrowser(opts?: {
3
3
  headed?: boolean;
4
4
  platform?: string;
5
- }
6
- export declare function getBrowser(opts?: LaunchOptions): Promise<{
5
+ }): Promise<{
7
6
  browser: Browser;
8
7
  context: BrowserContext;
9
8
  page: Page;
10
9
  }>;
10
+ export declare function getPage(): Promise<Page | null>;
11
11
  export declare function closeBrowser(platform?: string): Promise<void>;
12
12
  export declare function humanDelay(min?: number, max?: number): Promise<void>;
package/dist/browser.js CHANGED
@@ -1,46 +1,92 @@
1
- import { chromium } from 'playwright-extra';
2
- import StealthPlugin from 'puppeteer-extra-plugin-stealth';
3
- import { loadSession, saveSession } from './session.js';
4
- // @ts-ignore
5
- chromium.use(StealthPlugin());
1
+ import { chromium } from 'playwright';
2
+ import { promises as fs } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+ import { loadSession } from './session.js';
6
+ const STATE_FILE = join(homedir(), '.veil', 'browser.json');
7
+ // Singleton references (in-process)
6
8
  let _browser = null;
7
9
  let _context = null;
8
10
  let _page = null;
9
- export async function getBrowser(opts = {}) {
10
- const headless = !opts.headed;
11
- _browser = await chromium.launch({
12
- headless,
11
+ let _platform = 'default';
12
+ export async function ensureBrowser(opts = {}) {
13
+ const platform = opts.platform ?? 'default';
14
+ if (_browser && _context && _page && !_page.isClosed()) {
15
+ return { browser: _browser, context: _context, page: _page };
16
+ }
17
+ // Launch browser with stealth args
18
+ const browser = await chromium.launch({
19
+ headless: !opts.headed,
13
20
  args: [
14
- '--no-sandbox',
15
21
  '--disable-blink-features=AutomationControlled',
22
+ '--no-sandbox',
23
+ '--disable-setuid-sandbox',
16
24
  '--disable-infobars',
25
+ '--disable-dev-shm-usage',
26
+ '--disable-accelerated-2d-canvas',
27
+ '--no-first-run',
28
+ '--no-zygote',
29
+ '--disable-gpu',
17
30
  '--window-size=1280,800',
31
+ '--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
18
32
  ],
19
33
  });
20
- const storageState = opts.platform ? await loadSession(opts.platform) : undefined;
21
- _context = await _browser.newContext({
22
- storageState: storageState ?? undefined,
34
+ // Create context with realistic settings
35
+ const context = await browser.newContext({
23
36
  viewport: { width: 1280, height: 800 },
24
- userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
37
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
25
38
  locale: 'en-US',
26
- timezoneId: 'Europe/Bratislava',
39
+ timezoneId: 'America/New_York',
40
+ permissions: ['notifications'],
41
+ extraHTTPHeaders: {
42
+ 'Accept-Language': 'en-US,en;q=0.9',
43
+ },
44
+ });
45
+ // Inject stealth scripts
46
+ await context.addInitScript(() => {
47
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
48
+ window.chrome = { runtime: {} };
49
+ Object.defineProperty(navigator, 'plugins', {
50
+ get: () => [1, 2, 3, 4, 5],
51
+ });
52
+ Object.defineProperty(navigator, 'languages', {
53
+ get: () => ['en-US', 'en'],
54
+ });
27
55
  });
28
- _page = await _context.newPage();
29
- return { browser: _browser, context: _context, page: _page };
56
+ // Restore session cookies if available
57
+ const session = await loadSession(platform);
58
+ if (session?.cookies && session.cookies.length > 0) {
59
+ await context.addCookies(session.cookies);
60
+ }
61
+ const page = await context.newPage();
62
+ _browser = browser;
63
+ _context = context;
64
+ _page = page;
65
+ _platform = platform;
66
+ // Save state for reference
67
+ await fs.mkdir(join(homedir(), '.veil'), { recursive: true });
68
+ await fs.writeFile(STATE_FILE, JSON.stringify({
69
+ platform,
70
+ pid: process.pid,
71
+ timestamp: Date.now(),
72
+ }), 'utf-8').catch(() => { });
73
+ return { browser, context, page };
74
+ }
75
+ export async function getPage() {
76
+ if (_page && !_page.isClosed())
77
+ return _page;
78
+ return null;
30
79
  }
31
80
  export async function closeBrowser(platform) {
32
- if (_context && platform) {
33
- const state = await _context.storageState();
34
- await saveSession(platform, state);
35
- }
36
81
  if (_browser) {
37
- await _browser.close();
82
+ await _browser.close().catch(() => { });
38
83
  _browser = null;
39
84
  _context = null;
40
85
  _page = null;
41
86
  }
87
+ await fs.rm(STATE_FILE).catch(() => { });
42
88
  }
43
- export function humanDelay(min = 300, max = 900) {
44
- const ms = Math.floor(Math.random() * (max - min) + min);
45
- return new Promise((r) => setTimeout(r, ms));
89
+ export function humanDelay(min = 500, max = 1200) {
90
+ const delay = Math.floor(Math.random() * (max - min) + min);
91
+ return new Promise(r => setTimeout(r, delay));
46
92
  }
package/dist/index.js CHANGED
@@ -1,171 +1,386 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
- import { veilStartup } from './startup.js';
5
- import { loginCommand } from './commands/login.js';
6
- import { navigateCommand } from './commands/navigate.js';
7
- import { actCommand } from './commands/act.js';
8
- import { extractCommand } from './commands/extract.js';
9
- import { screenshotCommand } from './commands/screenshot.js';
10
- import { sessionListCommand, logoutCommand } from './commands/session.js';
11
- import { searchCommand } from './commands/search.js';
12
- import { startMcpServer } from './mcp.js';
4
+ import { ensureBrowser, closeBrowser, getPage } from './browser.js';
5
+ import { saveSession } from './session.js';
13
6
  const program = new Command();
14
7
  program
15
8
  .name('veil')
16
- .description(chalk.cyan('🕶️ Stealth browser CLI for AI agents'))
17
- .version('0.1.0');
18
- // --- Auth ---
9
+ .description('🕶️ OpenClaw browser remote stealth headless browser')
10
+ .version('0.2.0');
11
+ // ─── Session ──────────────────────────────────────────────────────────────────
19
12
  program
20
13
  .command('login <platform>')
21
- .description('Open visible browser for manual login, save session')
22
- .action(loginCommand);
23
- program
24
- .command('logout <platform>')
25
- .description('Remove saved session for a platform')
26
- .action(logoutCommand);
27
- // --- Browse ---
28
- program
29
- .command('navigate <url>')
30
- .description('Navigate to a URL in headless stealth mode')
31
- .option('-p, --platform <platform>', 'Use saved session for this platform')
32
- .option('-H, --headed', 'Run in visible browser mode')
33
- .option('--json', 'Output JSON')
34
- .action((url, opts) => navigateCommand(url, opts));
35
- program
36
- .command('act <instruction>')
37
- .description('Perform a natural language action in the browser')
38
- .option('-p, --platform <platform>', 'Use saved session for this platform')
39
- .option('-u, --url <url>', 'Navigate to this URL first')
40
- .option('-H, --headed', 'Run in visible browser mode')
41
- .option('--json', 'Output JSON')
42
- .action((instruction, opts) => actCommand(instruction, opts));
43
- program
44
- .command('extract <query>')
45
- .description('Extract data from current page as JSON')
46
- .option('-p, --platform <platform>', 'Use saved session for this platform')
47
- .option('-u, --url <url>', 'Navigate to this URL first')
48
- .option('-H, --headed', 'Run in visible browser mode')
49
- .option('--json', 'Output JSON')
50
- .action((query, opts) => extractCommand(query, opts));
51
- program
52
- .command('screenshot')
53
- .description('Take a screenshot of the current page')
54
- .option('-p, --platform <platform>', 'Use saved session for this platform')
55
- .option('-u, --url <url>', 'Navigate to this URL first')
56
- .option('-o, --output <path>', 'Output file path')
57
- .option('-H, --headed', 'Run in visible browser mode')
58
- .option('--json', 'Output JSON')
59
- .action((opts) => screenshotCommand(opts));
60
- // --- Search ---
61
- program
62
- .command('search <query>')
63
- .description('Search the web — google, bing, duckduckgo, or twitter')
64
- .option('-e, --engine <engines>', 'Comma-separated engines: google,bing,duckduckgo,twitter', 'google')
65
- .option('-p, --platform <platform>', 'Use saved session for platform search')
66
- .option('-l, --limit <n>', 'Max results', '10')
67
- .option('-H, --headed', 'Run in visible browser mode')
68
- .option('--json', 'Output JSON')
69
- .action((query, opts) => searchCommand(query, opts));
70
- // --- Sessions ---
71
- const sessionCmd = program.command('session').description('Manage saved sessions');
72
- sessionCmd.command('list').description('List all saved sessions').action(sessionListCommand);
73
- // --- Config ---
74
- program
75
- .command('config [key] [value]')
76
- .description('Set or show veil configuration')
77
- .action(async (key, value) => {
78
- const { promises: fs } = await import('fs');
79
- const { homedir } = await import('os');
80
- const { join } = await import('path');
81
- const configFile = join(homedir(), '.veil', 'config.json');
82
- await fs.mkdir(join(homedir(), '.veil'), { recursive: true });
83
- let config = {};
14
+ .description('Open visible browser to log in and save session (x, linkedin, reddit)')
15
+ .action(async (platform) => {
16
+ const platformUrls = {
17
+ x: 'https://x.com/login',
18
+ twitter: 'https://x.com/login',
19
+ linkedin: 'https://www.linkedin.com/login',
20
+ reddit: 'https://www.reddit.com/login',
21
+ bluesky: 'https://bsky.app',
22
+ };
23
+ const url = platformUrls[platform.toLowerCase()] ?? `https://${platform}`;
24
+ const { browser, context, page } = await ensureBrowser({ headed: true, platform });
25
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
26
+ console.log(chalk.cyan(`\n🔐 Log into ${platform} in the browser window.`));
27
+ console.log(chalk.gray(' Press Enter here when done.\n'));
28
+ await new Promise(res => process.stdin.once('data', () => res()));
29
+ await saveSession(platform, context);
30
+ console.log(chalk.green(`✅ Session saved for ${platform}`));
31
+ await browser.close();
32
+ });
33
+ program
34
+ .command('sessions')
35
+ .description('List saved sessions')
36
+ .action(async () => {
37
+ const { listSessions } = await import('./session.js');
38
+ const sessions = await listSessions();
39
+ if (sessions.length === 0) {
40
+ console.log(chalk.gray('No sessions saved. Run: veil login <platform>'));
41
+ }
42
+ else {
43
+ sessions.forEach(s => console.log(chalk.green(` ✓ ${s}`)));
44
+ }
45
+ });
46
+ // ─── Navigation ───────────────────────────────────────────────────────────────
47
+ program
48
+ .command('go <url>')
49
+ .description('Navigate to a URL')
50
+ .option('--platform <platform>', 'Platform for session restore', 'default')
51
+ .option('--wait <event>', 'Wait event: load|domcontentloaded|networkidle', 'domcontentloaded')
52
+ .option('--timeout <ms>', 'Timeout in ms', '30000')
53
+ .action(async (url, opts) => {
54
+ const { page } = await ensureBrowser({ platform: opts.platform });
55
+ try {
56
+ await page.goto(url, { waitUntil: opts.wait, timeout: parseInt(opts.timeout) });
57
+ const title = await page.title();
58
+ const finalUrl = page.url();
59
+ console.log(JSON.stringify({ ok: true, url: finalUrl, title }));
60
+ }
61
+ catch (err) {
62
+ console.log(JSON.stringify({ ok: false, error: err.message }));
63
+ process.exit(1);
64
+ }
65
+ });
66
+ program
67
+ .command('url')
68
+ .description('Get current URL')
69
+ .action(async () => {
70
+ const page = await getPage();
71
+ if (!page) {
72
+ console.log(JSON.stringify({ ok: false, error: 'No browser session open' }));
73
+ process.exit(1);
74
+ }
75
+ console.log(JSON.stringify({ ok: true, url: page.url(), title: await page.title() }));
76
+ });
77
+ program
78
+ .command('back')
79
+ .description('Navigate back')
80
+ .action(async () => {
81
+ const page = await getPage();
82
+ if (!page) {
83
+ console.log(JSON.stringify({ ok: false, error: 'No browser session open' }));
84
+ process.exit(1);
85
+ }
86
+ await page.goBack();
87
+ console.log(JSON.stringify({ ok: true, url: page.url() }));
88
+ });
89
+ // ─── Interaction ──────────────────────────────────────────────────────────────
90
+ program
91
+ .command('click <selector>')
92
+ .description('Click an element by CSS selector or data-testid')
93
+ .option('--nth <n>', 'Which match (0-indexed)', '0')
94
+ .option('--force', 'Force click (bypass overlays)', false)
95
+ .option('--timeout <ms>', 'Timeout', '5000')
96
+ .action(async (selector, opts) => {
97
+ const page = await getPage();
98
+ if (!page) {
99
+ console.log(JSON.stringify({ ok: false, error: 'No browser open. Run: veil go <url>' }));
100
+ process.exit(1);
101
+ }
102
+ try {
103
+ const el = page.locator(selector).nth(parseInt(opts.nth));
104
+ await el.waitFor({ timeout: parseInt(opts.timeout) });
105
+ await el.click({ force: opts.force, timeout: parseInt(opts.timeout) });
106
+ console.log(JSON.stringify({ ok: true, selector, nth: opts.nth }));
107
+ }
108
+ catch (err) {
109
+ console.log(JSON.stringify({ ok: false, error: err.message, selector }));
110
+ process.exit(1);
111
+ }
112
+ });
113
+ program
114
+ .command('type <selector> <text>')
115
+ .description('Type text into an element')
116
+ .option('--clear', 'Clear field first', false)
117
+ .option('--delay <ms>', 'Delay between keystrokes in ms', '40')
118
+ .option('--nth <n>', 'Which match (0-indexed)', '0')
119
+ .action(async (selector, text, opts) => {
120
+ const page = await getPage();
121
+ if (!page) {
122
+ console.log(JSON.stringify({ ok: false, error: 'No browser open' }));
123
+ process.exit(1);
124
+ }
125
+ try {
126
+ const el = page.locator(selector).nth(parseInt(opts.nth));
127
+ await el.waitFor({ timeout: 5000 });
128
+ if (opts.clear)
129
+ await el.clear();
130
+ await el.click({ force: true });
131
+ await page.keyboard.type(text, { delay: parseInt(opts.delay) });
132
+ console.log(JSON.stringify({ ok: true, selector, typed: text }));
133
+ }
134
+ catch (err) {
135
+ console.log(JSON.stringify({ ok: false, error: err.message, selector }));
136
+ process.exit(1);
137
+ }
138
+ });
139
+ program
140
+ .command('press <key>')
141
+ .description('Press a keyboard key (Enter, Tab, Escape, ArrowDown...)')
142
+ .action(async (key) => {
143
+ const page = await getPage();
144
+ if (!page) {
145
+ console.log(JSON.stringify({ ok: false, error: 'No browser open' }));
146
+ process.exit(1);
147
+ }
148
+ await page.keyboard.press(key);
149
+ console.log(JSON.stringify({ ok: true, key }));
150
+ });
151
+ program
152
+ .command('scroll <direction>')
153
+ .description('Scroll page: up, down, top, bottom')
154
+ .option('--amount <px>', 'Pixels to scroll', '600')
155
+ .action(async (direction, opts) => {
156
+ const page = await getPage();
157
+ if (!page) {
158
+ console.log(JSON.stringify({ ok: false, error: 'No browser open' }));
159
+ process.exit(1);
160
+ }
161
+ const amount = parseInt(opts.amount);
162
+ const scrollMap = {
163
+ down: `window.scrollBy(0, ${amount})`,
164
+ up: `window.scrollBy(0, -${amount})`,
165
+ top: 'window.scrollTo(0, 0)',
166
+ bottom: 'window.scrollTo(0, document.body.scrollHeight)',
167
+ };
168
+ await page.evaluate(scrollMap[direction] ?? scrollMap.down);
169
+ console.log(JSON.stringify({ ok: true, direction, amount }));
170
+ });
171
+ program
172
+ .command('wait <ms>')
173
+ .description('Wait for N milliseconds')
174
+ .action(async (ms) => {
175
+ await new Promise(r => setTimeout(r, parseInt(ms)));
176
+ console.log(JSON.stringify({ ok: true, waited: parseInt(ms) }));
177
+ });
178
+ program
179
+ .command('wait-for <selector>')
180
+ .description('Wait until selector appears on the page')
181
+ .option('--timeout <ms>', 'Timeout', '10000')
182
+ .action(async (selector, opts) => {
183
+ const page = await getPage();
184
+ if (!page) {
185
+ console.log(JSON.stringify({ ok: false, error: 'No browser open' }));
186
+ process.exit(1);
187
+ }
84
188
  try {
85
- config = JSON.parse(await fs.readFile(configFile, 'utf-8'));
86
- }
87
- catch { }
88
- // Show config
89
- if (!key || key === 'show') {
90
- console.log(chalk.cyan('\n🕶️ veil config\n'));
91
- console.log(chalk.bold(' LLM (for veil act):'));
92
- console.log(` provider: ${chalk.green(config.llm?.provider ?? 'auto-detect')}`);
93
- console.log(` model: ${chalk.green(config.llm?.model ?? 'auto-detect')}`);
94
- console.log(` apiKey: ${config.llm?.apiKey ? chalk.green('set') : chalk.gray('not set')}`);
95
- console.log(` baseUrl: ${chalk.green(config.llm?.baseUrl ?? 'http://localhost:11434 (ollama default)')}`);
96
- console.log(chalk.bold('\n CAPTCHA:'));
97
- console.log(` provider: ${chalk.green(config.captcha?.provider ?? 'local (FlareSolverr + Ollama)')}`);
98
- console.log(` apiKey: ${config.captcha?.apiKey ? chalk.green('set') : chalk.gray('not set')}`);
99
- console.log(chalk.bold('\n Quick setup:'));
100
- console.log(chalk.gray(' Ollama: veil config llm.provider ollama'));
101
- console.log(chalk.gray(' Model: veil config llm.model llama3.2'));
102
- console.log(chalk.gray(' Ollama URL:veil config llm.baseUrl http://localhost:11434'));
103
- console.log(chalk.gray(' OpenAI: veil config llm.provider openai && veil config llm.apiKey sk-...'));
104
- console.log('');
105
- return;
106
- }
107
- // Set config key
108
- const parts = key.split('.');
109
- let obj = config;
110
- for (let i = 0; i < parts.length - 1; i++) {
111
- obj[parts[i]] = obj[parts[i]] ?? {};
112
- obj = obj[parts[i]];
113
- }
114
- obj[parts[parts.length - 1]] = value;
115
- await fs.writeFile(configFile, JSON.stringify(config, null, 2), 'utf-8');
116
- console.log(chalk.green(`✅ Set ${key} = ${value}`));
117
- });
118
- // --- Daemon mode ---
119
- program
120
- .command('daemon [instruction...]')
121
- .description('Run veil continuously at intervals (like posts every 5 minutes)')
122
- .option('--platform <platform>', 'Platform: x, linkedin, reddit, bluesky', 'x')
123
- .option('--interval <minutes>', 'Run every N minutes', '5')
124
- .option('--max-runs <n>', 'Stop after N runs (default: infinite)')
125
- .option('--stop-on <errors...>', 'Stop if error contains this text')
126
- .action(async (instructions, opts) => {
127
- const instruction = instructions?.join(' ');
128
- if (!instruction) {
129
- console.error(chalk.red('Error: instruction required'));
130
- console.error(chalk.gray('Usage: veil daemon "Like posts about AI" --interval 5 --platform x'));
131
- process.exit(1);
132
- }
133
- const config = {
134
- instruction,
135
- interval: parseInt(opts.interval, 10),
136
- platform: opts.platform,
137
- maxRuns: opts.maxRuns ? parseInt(opts.maxRuns, 10) : Infinity,
138
- stopOn: opts.stopOn || [],
189
+ await page.waitForSelector(selector, { timeout: parseInt(opts.timeout) });
190
+ console.log(JSON.stringify({ ok: true, selector }));
191
+ }
192
+ catch (err) {
193
+ console.log(JSON.stringify({ ok: false, error: `Timeout waiting for: ${selector}` }));
194
+ process.exit(1);
195
+ }
196
+ });
197
+ // ─── Reading / Extraction ─────────────────────────────────────────────────────
198
+ program
199
+ .command('read [selector]')
200
+ .description('Read text from page or specific element')
201
+ .option('--all', 'Return all matches as array', false)
202
+ .option('--attr <attribute>', 'Read attribute instead of text (e.g. href, src)')
203
+ .action(async (selector, opts) => {
204
+ const page = await getPage();
205
+ if (!page) {
206
+ console.log(JSON.stringify({ ok: false, error: 'No browser open' }));
207
+ process.exit(1);
208
+ }
209
+ try {
210
+ if (!selector) {
211
+ // Full page text
212
+ const text = await page.evaluate(() => document.body.innerText);
213
+ console.log(JSON.stringify({ ok: true, text: text.slice(0, 5000) }));
214
+ return;
215
+ }
216
+ if (opts.all) {
217
+ const items = await page.locator(selector).allTextContents();
218
+ console.log(JSON.stringify({ ok: true, items }));
219
+ }
220
+ else if (opts.attr) {
221
+ const val = await page.locator(selector).first().getAttribute(opts.attr);
222
+ console.log(JSON.stringify({ ok: true, value: val }));
223
+ }
224
+ else {
225
+ const text = await page.locator(selector).first().textContent();
226
+ console.log(JSON.stringify({ ok: true, text }));
227
+ }
228
+ }
229
+ catch (err) {
230
+ console.log(JSON.stringify({ ok: false, error: err.message }));
231
+ process.exit(1);
232
+ }
233
+ });
234
+ program
235
+ .command('snapshot')
236
+ .description('Get full page accessibility snapshot (ARIA tree) for reasoning')
237
+ .option('--max <chars>', 'Max chars to return', '8000')
238
+ .action(async (opts) => {
239
+ const page = await getPage();
240
+ if (!page) {
241
+ console.log(JSON.stringify({ ok: false, error: 'No browser open' }));
242
+ process.exit(1);
243
+ }
244
+ // Use DOM snapshot instead of deprecated accessibility
245
+ const snapshot = await page.evaluate((max) => {
246
+ function nodeToObj(el, depth = 0) {
247
+ if (depth > 8)
248
+ return null;
249
+ const obj = {
250
+ tag: el.tagName?.toLowerCase(),
251
+ role: el.getAttribute('role'),
252
+ label: el.getAttribute('aria-label'),
253
+ testid: el.getAttribute('data-testid'),
254
+ text: el instanceof HTMLElement && !el.children.length ? el.innerText?.slice(0, 100) : undefined,
255
+ href: el instanceof HTMLAnchorElement ? el.href : undefined,
256
+ };
257
+ // Remove undefined keys
258
+ Object.keys(obj).forEach(k => obj[k] === undefined && delete obj[k]);
259
+ const children = Array.from(el.children)
260
+ .map(c => nodeToObj(c, depth + 1))
261
+ .filter(Boolean)
262
+ .slice(0, 10);
263
+ if (children.length)
264
+ obj.children = children;
265
+ return obj;
266
+ }
267
+ return JSON.stringify(nodeToObj(document.body), null, 2).slice(0, max);
268
+ }, parseInt(opts.max));
269
+ console.log(JSON.stringify({ ok: true, snapshot, url: page.url() }));
270
+ });
271
+ program
272
+ .command('find <text>')
273
+ .description('Check if text exists on the current page')
274
+ .action(async (text) => {
275
+ const page = await getPage();
276
+ if (!page) {
277
+ console.log(JSON.stringify({ ok: false, error: 'No browser open' }));
278
+ process.exit(1);
279
+ }
280
+ const found = await page.getByText(text).first().isVisible().catch(() => false);
281
+ console.log(JSON.stringify({ ok: true, found, text }));
282
+ });
283
+ program
284
+ .command('exists <selector>')
285
+ .description('Check if a selector exists on the page')
286
+ .action(async (selector) => {
287
+ const page = await getPage();
288
+ if (!page) {
289
+ console.log(JSON.stringify({ ok: false, error: 'No browser open' }));
290
+ process.exit(1);
291
+ }
292
+ const count = await page.locator(selector).count();
293
+ console.log(JSON.stringify({ ok: true, exists: count > 0, count, selector }));
294
+ });
295
+ // ─── Screenshots ──────────────────────────────────────────────────────────────
296
+ program
297
+ .command('shot [filename]')
298
+ .description('Take a screenshot')
299
+ .option('--selector <sel>', 'Screenshot specific element')
300
+ .option('--full', 'Full page screenshot', false)
301
+ .action(async (filename, opts) => {
302
+ const page = await getPage();
303
+ if (!page) {
304
+ console.log(JSON.stringify({ ok: false, error: 'No browser open' }));
305
+ process.exit(1);
306
+ }
307
+ const path = filename ?? `veil-${Date.now()}.png`;
308
+ if (opts.selector) {
309
+ await page.locator(opts.selector).first().screenshot({ path });
310
+ }
311
+ else {
312
+ await page.screenshot({ path, fullPage: opts.full });
313
+ }
314
+ console.log(JSON.stringify({ ok: true, path }));
315
+ });
316
+ // ─── Evaluate ─────────────────────────────────────────────────────────────────
317
+ program
318
+ .command('eval <script>')
319
+ .description('Run JavaScript in the browser and return result')
320
+ .action(async (script) => {
321
+ const page = await getPage();
322
+ if (!page) {
323
+ console.log(JSON.stringify({ ok: false, error: 'No browser open' }));
324
+ process.exit(1);
325
+ }
326
+ try {
327
+ const result = await page.evaluate(script);
328
+ console.log(JSON.stringify({ ok: true, result }));
329
+ }
330
+ catch (err) {
331
+ console.log(JSON.stringify({ ok: false, error: err.message }));
332
+ process.exit(1);
333
+ }
334
+ });
335
+ // ─── Session management ───────────────────────────────────────────────────────
336
+ program
337
+ .command('open <platform>')
338
+ .description('Open a browser session using saved login cookies for a platform')
339
+ .option('--headed', 'Show browser window', false)
340
+ .action(async (platform, opts) => {
341
+ const { browser, context, page } = await ensureBrowser({ headed: opts.headed, platform });
342
+ const platformUrls = {
343
+ x: 'https://x.com/home',
344
+ twitter: 'https://x.com/home',
345
+ linkedin: 'https://www.linkedin.com/feed',
346
+ reddit: 'https://www.reddit.com',
347
+ bluesky: 'https://bsky.app',
139
348
  };
140
- const { daemonCommand } = await import('./commands/daemon.js');
141
- const { aiAct } = await import('./ai.js');
142
- await daemonCommand(config, async (page) => {
143
- const result = await aiAct(page, config.instruction, {});
144
- if (!result.success)
145
- throw new Error(result.error);
146
- });
147
- });
148
- // --- MCP Server ---
149
- program
150
- .command('serve')
151
- .description('Start veil as an MCP tool server for AI agents')
152
- .option('--port <port>', 'Port to listen on', '3456')
153
- .action((opts) => startMcpServer(parseInt(opts.port)));
154
- // --- Status ---
349
+ const url = platformUrls[platform.toLowerCase()] ?? `https://${platform}`;
350
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
351
+ const title = await page.title();
352
+ console.log(JSON.stringify({ ok: true, platform, url: page.url(), title }));
353
+ });
354
+ program
355
+ .command('close')
356
+ .description('Close the current browser session')
357
+ .option('--platform <platform>', 'Platform to close', 'default')
358
+ .action(async (opts) => {
359
+ await closeBrowser(opts.platform);
360
+ console.log(JSON.stringify({ ok: true }));
361
+ });
362
+ // ─── Status ───────────────────────────────────────────────────────────────────
155
363
  program
156
364
  .command('status')
157
- .description('Show veil status and saved sessions')
365
+ .description('Show veil status')
158
366
  .action(async () => {
159
367
  const { listSessions } = await import('./session.js');
160
368
  const { isFlareSolverrUp } = await import('./local-captcha.js');
161
369
  const sessions = await listSessions();
162
370
  const flare = await isFlareSolverrUp();
163
- console.log(chalk.cyan('\n🕶️ veil v0.1.0\n'));
164
- console.log(` Sessions: ${sessions.length > 0 ? chalk.bold.green(sessions.join(', ')) : chalk.gray('none')}`);
165
- console.log(` FlareSolverr: ${flare ? chalk.green('running') : chalk.gray('not running')}`);
166
- console.log(` MCP: ${chalk.gray('veil serve --port 3456')}`);
371
+ console.log(chalk.cyan('\n🕶️ veil v0.2.0 — OpenClaw Browser Remote\n'));
372
+ console.log(` Sessions: ${sessions.length > 0 ? chalk.green(sessions.join(', ')) : chalk.gray('none')}`);
373
+ console.log(` FlareSolverr: ${flare ? chalk.green('running') : chalk.gray('not running (auto-starts on use)')}`);
374
+ console.log('');
375
+ console.log(chalk.gray(' Quick reference:'));
376
+ console.log(chalk.gray(' veil login x # save X session'));
377
+ console.log(chalk.gray(' veil open x # restore X session'));
378
+ console.log(chalk.gray(' veil go <url> # navigate'));
379
+ console.log(chalk.gray(' veil snapshot # read current page'));
380
+ console.log(chalk.gray(' veil click <sel> # click element'));
381
+ console.log(chalk.gray(' veil type <sel> <text>'));
382
+ console.log(chalk.gray(' veil read [sel] # extract text'));
383
+ console.log(chalk.gray(' veil shot # screenshot'));
167
384
  console.log('');
168
385
  });
169
- // Boot FlareSolverr in background on any command
170
- veilStartup();
171
386
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veil-browser",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "Stealth browser CLI for AI agents — bypass bot detection, persist sessions, local CAPTCHA solving, MCP server",
5
5
  "keywords": [
6
6
  "browser",
@@ -31,7 +31,8 @@
31
31
  },
32
32
  "files": [
33
33
  "dist/**/*",
34
- "README.md"
34
+ "README.md",
35
+ "SKILL.md"
35
36
  ],
36
37
  "dependencies": {
37
38
  "chalk": "^5.3.0",