cdpilot 0.9.5 → 0.9.7
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/README.md +158 -13
- package/bin/cdpilot.js +399 -20
- package/package.json +3 -3
- package/src/cdpilot.py +2619 -293
package/bin/cdpilot.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* cdpilot —
|
|
4
|
+
* cdpilot — Browser automation CLI, zero npm dependencies
|
|
5
5
|
* Entry point: detects Python, finds browser, delegates to cdpilot.py
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -19,6 +19,316 @@ const os = require('os');
|
|
|
19
19
|
|
|
20
20
|
const SCRIPT = path.join(__dirname, '..', 'src', 'cdpilot.py');
|
|
21
21
|
const VERSION = require('../package.json').version;
|
|
22
|
+
const SRC_DIR = path.join(__dirname, '..', 'src');
|
|
23
|
+
|
|
24
|
+
// Python runs src/cdpilot.py through the import system (runpy) instead of as a
|
|
25
|
+
// script: a script named on the command line is recompiled on every call —
|
|
26
|
+
// 0.15 s of the ~0.3 s Python start-up measured on 2026-11-08 for the 23k-line
|
|
27
|
+
// file — while an imported module keeps its bytecode in src/__pycache__/.
|
|
28
|
+
// sys.argv ends up exactly as for `python cdpilot.py <args>` (alter_sys), and
|
|
29
|
+
// an unwritable src/ only means Python skips the cache, never an error.
|
|
30
|
+
// An interpreter started with -c puts '' (the working directory) first on
|
|
31
|
+
// sys.path, so a runpy.py or json.py planted in an untrusted checkout would run
|
|
32
|
+
// before cdpilot does (2026-11-10 review); the loader drops that entry before
|
|
33
|
+
// any other import. `python src/cdpilot.py` had src/ there instead.
|
|
34
|
+
const PY_LOADER = "import sys; sys.path[:] = [p for p in sys.path if p]; sys.path.insert(0, sys.argv[1]); del sys.argv[1]; "
|
|
35
|
+
+ "import runpy; runpy.run_module('cdpilot', run_name='__main__', alter_sys=True)";
|
|
36
|
+
function pyArgs(...args) {
|
|
37
|
+
return ['-c', PY_LOADER, SRC_DIR, ...args];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The interpreter choice is remembered in CDPILOT_HOME/python.json for a day:
|
|
41
|
+
// probing candidates with `--version` cost 50-150 ms per command (a shell and
|
|
42
|
+
// a Python start per candidate tried). CDPILOT_PYTHON is part of the key, an
|
|
43
|
+
// absolute path that vanished is re-probed, and a failed spawn clears the entry.
|
|
44
|
+
const PYTHON_CACHE_TTL_MS = 24 * 3600 * 1000;
|
|
45
|
+
function pythonCachePath() {
|
|
46
|
+
return path.join(cdpilotHome(), 'python.json');
|
|
47
|
+
}
|
|
48
|
+
function readPythonCache() {
|
|
49
|
+
try {
|
|
50
|
+
const c = JSON.parse(fs.readFileSync(pythonCachePath(), 'utf8'));
|
|
51
|
+
if (c.python && c.key === (process.env.CDPILOT_PYTHON || '')
|
|
52
|
+
&& Date.now() - c.at < PYTHON_CACHE_TTL_MS
|
|
53
|
+
&& (!path.isAbsolute(c.python) || fs.existsSync(c.python))) {
|
|
54
|
+
return c.python;
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function writePythonCache(python) {
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(cdpilotHome(), { recursive: true });
|
|
62
|
+
fs.writeFileSync(pythonCachePath(),
|
|
63
|
+
JSON.stringify({ python, key: process.env.CDPILOT_PYTHON || '', at: Date.now() }));
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
function clearPythonCache() {
|
|
67
|
+
try { fs.unlinkSync(pythonCachePath()); } catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Warm process (daemon) ──
|
|
71
|
+
// CDPILOT_DAEMON=1: page commands go to one warm Python process per project
|
|
72
|
+
// (src/cdpilot.py --_daemon-serve) over a Unix socket; it keeps the module and
|
|
73
|
+
// the asyncio loop between calls — the CDP connections a request opened are
|
|
74
|
+
// closed at its end, as a process exit closed them (2026-11-08: a
|
|
75
|
+
// fresh process spent ~110 ms in node, ~140 ms starting Python and ~500 ms
|
|
76
|
+
// setting the CDP session up before `go` even began). The client falls back
|
|
77
|
+
// to a fresh process whenever the daemon is missing, busy (no hello within
|
|
78
|
+
// DAEMON_HELLO_MS), of another version, or refuses the command — always before
|
|
79
|
+
// anything ran, so a command never runs twice. POSIX only for now.
|
|
80
|
+
const net = require('net');
|
|
81
|
+
const DAEMON_SERVE_FLAG = '--_daemon-serve';
|
|
82
|
+
const DAEMON_HELLO_MS = 150;
|
|
83
|
+
// Mirrors DAEMON_SPAWN_CMDS in src/cdpilot.py (a test keeps them identical):
|
|
84
|
+
// lifecycle, servers and forks, long-running loops, the daemon itself.
|
|
85
|
+
const DAEMON_SPAWN_CMDS = new Set([
|
|
86
|
+
'launch', 'stop', 'stop-all', 'project-stop', 'connect', 'disconnect',
|
|
87
|
+
'serve', 'mcp', 'watch', 'setup', 'status', 'daemon', 'replay', 'run',
|
|
88
|
+
'blog', 'bot-auth', 'browser', 'test', 'trace', 'heal', 'health',
|
|
89
|
+
'session', 'sessions', 'session-close', 'projects', 'proxy', 'extensions',
|
|
90
|
+
'tls-proxy', 'captcha-wait', 'help', '--help', '-h']);
|
|
91
|
+
// "cmd sub" pairs that run fresh while the command itself is served warm
|
|
92
|
+
// (agent observe/act are page steps; agent twitter logs in and posts).
|
|
93
|
+
const DAEMON_SPAWN_SUBCMDS = new Set(['agent twitter']);
|
|
94
|
+
|
|
95
|
+
function daemonEnabled() {
|
|
96
|
+
return process.env.CDPILOT_DAEMON === '1' && process.platform !== 'win32';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function daemonEligible(cmd, args) {
|
|
100
|
+
if (!daemonEnabled() || !cmd || DAEMON_SPAWN_CMDS.has(cmd) || cmd.startsWith('--_')) return false;
|
|
101
|
+
if (DAEMON_SPAWN_SUBCMDS.has(`${cmd} ${args[1] || ''}`)) return false;
|
|
102
|
+
// --timeout ends its process with os._exit: a fresh process for it.
|
|
103
|
+
if (commandIndex(args) !== 0 || process.env.CDPILOT_TIMEOUT) return false;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// stdin a command reads (cmd_batch: always; cmd_check: without an argument)
|
|
108
|
+
// travels inside the request; the daemon has no terminal.
|
|
109
|
+
function daemonStdin(cmd, args) {
|
|
110
|
+
if (process.stdin.isTTY) return undefined;
|
|
111
|
+
if (cmd !== 'batch' && !(cmd === 'check' && args.length === 1)) return undefined;
|
|
112
|
+
try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The daemon is keyed by everything src/cdpilot.py reads at import time, so a
|
|
116
|
+
// request never meets a process set up for another project, port, browser,
|
|
117
|
+
// home or interpreter: a different key is a different socket and daemon.
|
|
118
|
+
function daemonKey(env, python) {
|
|
119
|
+
const parts = { python, version: VERSION, src: SRC_DIR, home: env.HOME || '' };
|
|
120
|
+
for (const k of Object.keys(env).sort()) {
|
|
121
|
+
if (k.startsWith('CDPILOT_') || k.startsWith('CDP_') || k === 'CHROME_BIN') parts[k] = env[k];
|
|
122
|
+
}
|
|
123
|
+
return require('crypto').createHash('sha1').update(JSON.stringify(parts)).digest('hex').slice(0, 16);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// sun_path holds 104 bytes on macOS: a long CDPILOT_HOME moves the socket to
|
|
127
|
+
// a private per-user directory — $XDG_RUNTIME_DIR when set (Linux desktops),
|
|
128
|
+
// else one under the temp dir (the key still includes the home).
|
|
129
|
+
function daemonDirs() {
|
|
130
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
131
|
+
const runtime = process.env.XDG_RUNTIME_DIR
|
|
132
|
+
? path.join(process.env.XDG_RUNTIME_DIR, 'cdpilot-daemon')
|
|
133
|
+
: path.join(os.tmpdir(), `cdpilot-${uid}-daemon`);
|
|
134
|
+
return [path.join(cdpilotHome(), 'daemon'), runtime];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function daemonSockPath(key) {
|
|
138
|
+
const [home, runtime] = daemonDirs();
|
|
139
|
+
const p = path.join(home, `${key}.sock`);
|
|
140
|
+
return Buffer.byteLength(p) <= 100 ? p : path.join(runtime, `${key}.sock`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A socket is only used when its directory and the socket itself belong to
|
|
144
|
+
// this user and nobody else may write there: in a shared temp dir another
|
|
145
|
+
// user could otherwise plant a fake daemon and receive argv, cwd and stdin.
|
|
146
|
+
function daemonPathSafe(sockPath) {
|
|
147
|
+
if (typeof process.getuid !== 'function') return true;
|
|
148
|
+
const uid = process.getuid();
|
|
149
|
+
try {
|
|
150
|
+
const dir = fs.lstatSync(path.dirname(sockPath));
|
|
151
|
+
if (!dir.isDirectory() || dir.uid !== uid || (dir.mode & 0o077) !== 0) return false;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return e.code === 'ENOENT'; // no directory yet: startDaemon creates it 0700
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const st = fs.lstatSync(sockPath);
|
|
157
|
+
return st.isSocket() && st.uid === uid;
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return e.code === 'ENOENT'; // no socket yet: the daemon may be started here
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// One request on one connection. Resolves {handled: true, code} when the
|
|
164
|
+
// daemon ran the command (every output frame went through onFrame), or
|
|
165
|
+
// {handled: false, reason} when nothing ran and the caller must spawn.
|
|
166
|
+
function daemonRequest(sockPath, payload, onFrame) {
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
let settled = false, hello = false, buf = '', scanFrom = 0, exitCode = null;
|
|
169
|
+
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
170
|
+
if (!daemonPathSafe(sockPath)) { done({ handled: false, reason: 'unsafe' }); return; }
|
|
171
|
+
const sock = net.createConnection(sockPath);
|
|
172
|
+
sock.setEncoding('utf8');
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
if (!hello) { sock.destroy(); done({ handled: false, reason: 'busy' }); }
|
|
175
|
+
}, DAEMON_HELLO_MS);
|
|
176
|
+
sock.on('error', (e) => {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
done(hello ? { handled: true, code: 1, error: `daemon connection failed: ${e.message}` }
|
|
179
|
+
: { handled: false, reason: e.code || 'error' });
|
|
180
|
+
});
|
|
181
|
+
sock.on('data', (chunk) => {
|
|
182
|
+
buf += chunk;
|
|
183
|
+
let nl;
|
|
184
|
+
while ((nl = buf.indexOf('\n', scanFrom)) >= 0) {
|
|
185
|
+
const line = buf.slice(0, nl);
|
|
186
|
+
buf = buf.slice(nl + 1);
|
|
187
|
+
scanFrom = 0;
|
|
188
|
+
let msg;
|
|
189
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
190
|
+
if (!hello) {
|
|
191
|
+
hello = true;
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
if (msg.version !== VERSION) {
|
|
194
|
+
// An older install's daemon: ask it to leave, run this call fresh.
|
|
195
|
+
sock.end(JSON.stringify({ op: 'shutdown' }) + '\n');
|
|
196
|
+
done({ handled: false, reason: 'version' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
sock.write(JSON.stringify(payload) + '\n');
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (msg.fallback !== undefined) {
|
|
203
|
+
done({ handled: false, reason: String(msg.fallback) });
|
|
204
|
+
sock.destroy();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (msg.exit !== undefined) {
|
|
208
|
+
// The last frame: the command ran and every output frame is through. Do not wait
|
|
209
|
+
// for the daemon to close the socket (K-0027: on Linux it held it open for 10 s).
|
|
210
|
+
exitCode = msg.exit;
|
|
211
|
+
done({ handled: true, code: exitCode });
|
|
212
|
+
sock.destroy();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
onFrame(msg);
|
|
216
|
+
}
|
|
217
|
+
scanFrom = buf.length; // a frame longer than one chunk: do not rescan it
|
|
218
|
+
});
|
|
219
|
+
sock.on('close', () => {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
if (exitCode !== null) done({ handled: true, code: exitCode });
|
|
222
|
+
else done(hello ? { handled: true, code: 1, error: 'connection to the daemon was lost' }
|
|
223
|
+
: { handled: false, reason: 'closed' });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Fork the warm process for this key, detached; at most once per 5 s. The
|
|
229
|
+
// stamp is created exclusively (no symlink following, no second starter).
|
|
230
|
+
function startDaemon(sockPath, python, env) {
|
|
231
|
+
if (!daemonPathSafe(sockPath)) return false;
|
|
232
|
+
const stamp = `${sockPath}.starting`;
|
|
233
|
+
try {
|
|
234
|
+
fs.mkdirSync(path.dirname(sockPath), { recursive: true, mode: 0o700 });
|
|
235
|
+
let fd;
|
|
236
|
+
try {
|
|
237
|
+
fd = fs.openSync(stamp, 'wx', 0o600);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
if (e.code !== 'EEXIST') return false;
|
|
240
|
+
try { if (Date.now() - fs.lstatSync(stamp).mtimeMs < 5000) return false; } catch { return false; }
|
|
241
|
+
fs.unlinkSync(stamp); // a starter that never got to bind: try once more
|
|
242
|
+
fd = fs.openSync(stamp, 'wx', 0o600);
|
|
243
|
+
}
|
|
244
|
+
fs.writeSync(fd, String(process.pid));
|
|
245
|
+
fs.closeSync(fd);
|
|
246
|
+
const child = spawn(python, pyArgs(DAEMON_SERVE_FLAG, sockPath),
|
|
247
|
+
{ detached: true, stdio: 'ignore', env });
|
|
248
|
+
child.on('error', () => {});
|
|
249
|
+
child.unref();
|
|
250
|
+
return true;
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function daemonPing(sockPath) {
|
|
257
|
+
return new Promise((resolve) => {
|
|
258
|
+
let pong = null;
|
|
259
|
+
daemonRequest(sockPath, { op: 'ping' }, (m) => { if (m.pong) pong = m; })
|
|
260
|
+
.then(() => resolve(pong));
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function listDaemonSockets() {
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const dir of daemonDirs()) {
|
|
267
|
+
let names = [];
|
|
268
|
+
try { names = fs.readdirSync(dir); } catch { continue; }
|
|
269
|
+
for (const n of names) if (n.endsWith('.sock')) out.push(path.join(dir, n));
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// `cdpilot daemon start|stop|status`
|
|
275
|
+
async function daemonCommand(sub, python, env) {
|
|
276
|
+
const what = sub[0] || 'status';
|
|
277
|
+
const mine = daemonSockPath(daemonKey(env, python));
|
|
278
|
+
if (what === 'start') {
|
|
279
|
+
if (process.platform === 'win32') {
|
|
280
|
+
console.error('cdpilot daemon: not available on Windows yet');
|
|
281
|
+
return 1;
|
|
282
|
+
}
|
|
283
|
+
let pong = await daemonPing(mine);
|
|
284
|
+
if (!pong) {
|
|
285
|
+
startDaemon(mine, python, env);
|
|
286
|
+
for (let i = 0; i < 30 && !pong; i++) {
|
|
287
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
288
|
+
pong = await daemonPing(mine);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!pong) {
|
|
292
|
+
console.error('cdpilot daemon: did not start (see `cdpilot daemon status`)');
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
295
|
+
console.log(`daemon running: pid ${pong.pid}, socket ${mine}`);
|
|
296
|
+
if (!daemonEnabled()) console.log('set CDPILOT_DAEMON=1 to send commands to it');
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
if (what === 'stop') {
|
|
300
|
+
let n = 0;
|
|
301
|
+
for (const sock of listDaemonSockets()) {
|
|
302
|
+
const r = await daemonRequest(sock, { op: 'shutdown' }, () => {});
|
|
303
|
+
if (r.handled) n++;
|
|
304
|
+
else if (r.reason === 'ECONNREFUSED' || r.reason === 'ENOENT') {
|
|
305
|
+
try { fs.unlinkSync(sock); } catch {} // nobody listens: a leftover of a crash
|
|
306
|
+
} // busy (no hello in time): leave it, it is serving a command
|
|
307
|
+
}
|
|
308
|
+
console.log(n ? `stopped ${n} daemon${n === 1 ? '' : 's'}` : 'no daemon running');
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
if (what === 'status') {
|
|
312
|
+
const socks = listDaemonSockets();
|
|
313
|
+
let live = 0;
|
|
314
|
+
for (const sock of socks) {
|
|
315
|
+
const pong = await daemonPing(sock);
|
|
316
|
+
if (!pong) continue;
|
|
317
|
+
live++;
|
|
318
|
+
const up = Math.max(0, Math.round(Date.now() / 1000 - pong.started));
|
|
319
|
+
console.log(`${sock === mine ? '*' : ' '} pid ${pong.pid} served ${pong.served} up ${up}s idle-exit ${pong.idle}s ${sock}`);
|
|
320
|
+
}
|
|
321
|
+
if (!live) {
|
|
322
|
+
console.log('no daemon running' + (daemonEnabled() ? '' : ' (CDPILOT_DAEMON=1 starts one on the next command)'));
|
|
323
|
+
console.log(` socket: ${mine}`);
|
|
324
|
+
} else if (socks.includes(mine)) {
|
|
325
|
+
console.log('* = this project and environment');
|
|
326
|
+
}
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
console.error(`cdpilot daemon: unknown subcommand '${what}' (start | stop | status)`);
|
|
330
|
+
return 2;
|
|
331
|
+
}
|
|
22
332
|
|
|
23
333
|
// CDPILOT_HOME resolution — must match src/cdpilot.py exactly:
|
|
24
334
|
// CDPILOT_HOME = os.environ.get("CDPILOT_HOME") or os.path.expanduser("~/.cdpilot")
|
|
@@ -86,6 +396,8 @@ function findBrowser() {
|
|
|
86
396
|
// ── Python Detection ──
|
|
87
397
|
|
|
88
398
|
function findPython() {
|
|
399
|
+
const cached = readPythonCache();
|
|
400
|
+
if (cached) return cached;
|
|
89
401
|
// cdpilot.py uses PEP 604 unions (dict | None) → requires Python 3.10+.
|
|
90
402
|
// Prefer version-specific + Homebrew interpreters so a stale default python3
|
|
91
403
|
// (e.g. macOS 3.9) is skipped instead of selected and crashing at runtime.
|
|
@@ -100,6 +412,7 @@ function findPython() {
|
|
|
100
412
|
const ver = execSync(`${cmd} --version 2>&1`, { stdio: 'pipe' }).toString().trim();
|
|
101
413
|
const match = ver.match(/(\d+)\.(\d+)/);
|
|
102
414
|
if (match && (parseInt(match[1]) > 3 || (parseInt(match[1]) === 3 && parseInt(match[2]) >= 10))) {
|
|
415
|
+
writePythonCache(cmd);
|
|
103
416
|
return cmd;
|
|
104
417
|
}
|
|
105
418
|
} catch {}
|
|
@@ -374,7 +687,7 @@ function botAuthLabel(port) {
|
|
|
374
687
|
if (!signer) return fs.existsSync(path.join(home, 'bot-auth', 'config.json')) ? 'bot-auth: off' : null;
|
|
375
688
|
const python = findPython();
|
|
376
689
|
if (!python) return 'bot-auth: off';
|
|
377
|
-
const r = spawnSync(python,
|
|
690
|
+
const r = spawnSync(python, pyArgs('--_bot-auth-label'), {
|
|
378
691
|
encoding: 'utf-8', timeout: 20000,
|
|
379
692
|
env: { ...process.env, CDP_PORT: String(port), CDPILOT_LOG: '0' },
|
|
380
693
|
});
|
|
@@ -439,7 +752,7 @@ function resolveProjectConfig() {
|
|
|
439
752
|
|
|
440
753
|
function showHelp() {
|
|
441
754
|
console.log(`
|
|
442
|
-
cdpilot v${VERSION} —
|
|
755
|
+
cdpilot v${VERSION} — browser automation, zero npm dependencies
|
|
443
756
|
|
|
444
757
|
USAGE
|
|
445
758
|
cdpilot [--timeout <s>] <command> [args]
|
|
@@ -453,6 +766,14 @@ function showHelp() {
|
|
|
453
766
|
An auto-launched (or MCP-launched) browser closes after 15 min without a
|
|
454
767
|
cdpilot command or page change (CDPILOT_IDLE_CLOSE=<minutes>,
|
|
455
768
|
0 = never); an explicit launch stays open unless asked (below).
|
|
769
|
+
CDPILOT_DAEMON=1 Keep one warm Python process per project and send page
|
|
770
|
+
commands to it over a local socket (browser-free command
|
|
771
|
+
~270 -> ~140 ms, content/eval ~360 -> ~110 ms, M1 Max;
|
|
772
|
+
a fresh process when it is missing or busy; not with
|
|
773
|
+
--timeout; 'daemon status|stop'; idle exit after
|
|
774
|
+
CDPILOT_DAEMON_IDLE seconds, default 1800).
|
|
775
|
+
CDPILOT_VAR_HOSTS Hosts (comma-separated, host:port for another port)
|
|
776
|
+
an agent flow or replay step may type CDPILOT_VAR_* on
|
|
456
777
|
|
|
457
778
|
SETUP
|
|
458
779
|
setup Auto-detect browser, create isolated profile
|
|
@@ -590,6 +911,10 @@ function showHelp() {
|
|
|
590
911
|
--load-extension, CfT does not. Used instead of Chrome
|
|
591
912
|
while dev extensions are registered. Not for stealth.
|
|
592
913
|
health JSON status: alive, port, tabs, browser, today's crashes
|
|
914
|
+
daemon start|stop|status
|
|
915
|
+
The warm process behind CDPILOT_DAEMON=1: start it ahead
|
|
916
|
+
of time, stop every running one, or list them (pid,
|
|
917
|
+
requests served, uptime, socket).
|
|
593
918
|
|
|
594
919
|
PROJECTS
|
|
595
920
|
projects List all project browser instances
|
|
@@ -597,6 +922,19 @@ function showHelp() {
|
|
|
597
922
|
stop-all Stop all browser instances
|
|
598
923
|
|
|
599
924
|
AI AGENT
|
|
925
|
+
agent observe [--diff]
|
|
926
|
+
Page state as numbered @refs (+text); --diff = only
|
|
927
|
+
changes since last
|
|
928
|
+
agent act --ref @N [--action click|type|fill|hover|submit] [--text X]
|
|
929
|
+
Acts, prints the diff
|
|
930
|
+
agent act --url URL
|
|
931
|
+
Navigates silently, prints the diff
|
|
932
|
+
agent flow save <name> [--last N] | list | show <name> | delete <name>
|
|
933
|
+
Save the acts since the last navigation as a replayable
|
|
934
|
+
flow
|
|
935
|
+
agent run <name> [--var k=v] [--dry-run]
|
|
936
|
+
Replay by anchors, no model; exit 4 on drift with the
|
|
937
|
+
page, 2 for a missing or refused variable
|
|
600
938
|
mcp Start MCP server (stdin/stdout JSON-RPC)
|
|
601
939
|
--tools core|all|a,b (env CDPILOT_MCP_TOOLS): smaller tool list
|
|
602
940
|
|
|
@@ -821,22 +1159,7 @@ if (cmd === 'setup') {
|
|
|
821
1159
|
process.exit(0);
|
|
822
1160
|
}
|
|
823
1161
|
|
|
824
|
-
|
|
825
|
-
runStatus();
|
|
826
|
-
// Don't exit immediately — let http callback complete
|
|
827
|
-
} else {
|
|
828
|
-
// Pre-flight check on first run or 'launch' command
|
|
829
|
-
if (cmd === 'launch') {
|
|
830
|
-
preflight();
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// Delegate to Python
|
|
834
|
-
const python = findPython();
|
|
835
|
-
if (!python) {
|
|
836
|
-
console.error('Error: Python 3.10+ required. Install: https://www.python.org/downloads/');
|
|
837
|
-
process.exit(1);
|
|
838
|
-
}
|
|
839
|
-
|
|
1162
|
+
function pythonEnv() {
|
|
840
1163
|
const browser = findBrowser();
|
|
841
1164
|
const config = resolveProjectConfig();
|
|
842
1165
|
|
|
@@ -861,11 +1184,20 @@ if (cmd === 'status') {
|
|
|
861
1184
|
// (Chrome -> Chrome for Testing) on top of it.
|
|
862
1185
|
env.CDPILOT_CHROME_BIN_AUTO = '1';
|
|
863
1186
|
}
|
|
1187
|
+
return env;
|
|
1188
|
+
}
|
|
864
1189
|
|
|
865
|
-
|
|
1190
|
+
function spawnPython(python, args, env) {
|
|
1191
|
+
const child = spawn(python, pyArgs(...args), {
|
|
866
1192
|
stdio: 'inherit',
|
|
867
1193
|
env,
|
|
868
1194
|
});
|
|
1195
|
+
child.on('error', (err) => {
|
|
1196
|
+
// A remembered interpreter that is gone: forget it so the next call probes again.
|
|
1197
|
+
clearPythonCache();
|
|
1198
|
+
console.error(`cdpilot: could not start ${python}: ${err.message}`);
|
|
1199
|
+
process.exit(1);
|
|
1200
|
+
});
|
|
869
1201
|
|
|
870
1202
|
// A child killed by a signal reports code === null. `code || 0` turned that
|
|
871
1203
|
// into success, so an OOM-killed or SIGKILLed run looked fine to scripts
|
|
@@ -878,3 +1210,50 @@ if (cmd === 'status') {
|
|
|
878
1210
|
process.exit(code ?? 0);
|
|
879
1211
|
});
|
|
880
1212
|
}
|
|
1213
|
+
|
|
1214
|
+
if (cmd === 'status') {
|
|
1215
|
+
runStatus();
|
|
1216
|
+
// Don't exit immediately — let http callback complete
|
|
1217
|
+
} else {
|
|
1218
|
+
// Pre-flight check on first run or 'launch' command
|
|
1219
|
+
if (cmd === 'launch') {
|
|
1220
|
+
preflight();
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Delegate to Python
|
|
1224
|
+
const python = findPython();
|
|
1225
|
+
if (!python) {
|
|
1226
|
+
console.error('Error: Python 3.10+ required. Install: https://www.python.org/downloads/');
|
|
1227
|
+
process.exit(1);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const env = pythonEnv();
|
|
1231
|
+
|
|
1232
|
+
if (cmd === 'daemon') {
|
|
1233
|
+
daemonCommand(args.slice(1), python, env).then((code) => { process.exitCode = code; });
|
|
1234
|
+
} else if (daemonEligible(cmd, args)) {
|
|
1235
|
+
const sockPath = daemonSockPath(daemonKey(env, python));
|
|
1236
|
+
// env: the daemon applies the caller's environment to the request (proxy
|
|
1237
|
+
// and solver keys, PATH, TZ … are read when a command runs, not at import).
|
|
1238
|
+
const payload = { argv: args, cwd: process.cwd(), env };
|
|
1239
|
+
const stdin = daemonStdin(cmd, args);
|
|
1240
|
+
if (stdin !== undefined) payload.stdin = stdin;
|
|
1241
|
+
daemonRequest(sockPath, payload, (m) => {
|
|
1242
|
+
if (m.o !== undefined) process.stdout.write(m.o);
|
|
1243
|
+
if (m.e !== undefined) process.stderr.write(m.e);
|
|
1244
|
+
}).then((r) => {
|
|
1245
|
+
if (r.handled) {
|
|
1246
|
+
if (r.error) console.error(`cdpilot: ${r.error}`);
|
|
1247
|
+
process.exitCode = r.code;
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
// No daemon (yet): start one for the next call; this call runs fresh.
|
|
1251
|
+
if (['ENOENT', 'ECONNREFUSED', 'version', 'closed', 'stale'].includes(r.reason)) {
|
|
1252
|
+
startDaemon(sockPath, python, env);
|
|
1253
|
+
}
|
|
1254
|
+
spawnPython(python, args, env);
|
|
1255
|
+
});
|
|
1256
|
+
} else {
|
|
1257
|
+
spawnPython(python, args, env);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdpilot",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"mcpName": "io.github.mehmetnadir/cdpilot",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Browser automation CLI with zero npm dependencies (Python: websockets). Raw CDP, 70+ commands, friction ladder, 3-tier stealth, CAPTCHA + press-and-hold solvers, proxy pools, MCP server. No Puppeteer/Playwright/Selenium.",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cdpilot": "./bin/cdpilot.js",
|
|
8
8
|
"bctl": "./bin/cdpilot.js"
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"mcp",
|
|
21
21
|
"ai-agent",
|
|
22
22
|
"web-scraping",
|
|
23
|
-
"zero-
|
|
23
|
+
"zero-npm-dependencies",
|
|
24
24
|
"headless-browser",
|
|
25
25
|
"puppeteer-alternative",
|
|
26
26
|
"playwright-alternative",
|