@oya-ai/cli 0.1.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.
Files changed (2) hide show
  1. package/dist/index.js +861 -0
  2. package/package.json +29 -0
package/dist/index.js ADDED
@@ -0,0 +1,861 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { spawn } from "child_process";
5
+
6
+ // ../sdk/dist/index.js
7
+ var OyaError = class extends Error {
8
+ status;
9
+ body;
10
+ constructor(message, status, body) {
11
+ super(message);
12
+ this.name = "OyaError";
13
+ this.status = status;
14
+ this.body = body;
15
+ }
16
+ };
17
+ var Http = class {
18
+ constructor(baseUrl, apiKey, timeoutMs, fetchImpl) {
19
+ this.baseUrl = baseUrl;
20
+ this.apiKey = apiKey;
21
+ this.timeoutMs = timeoutMs;
22
+ this.fetchImpl = fetchImpl;
23
+ }
24
+ baseUrl;
25
+ apiKey;
26
+ timeoutMs;
27
+ fetchImpl;
28
+ async request(method, path, body, timeoutMs = this.timeoutMs, headers = {}) {
29
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
30
+ method,
31
+ headers: {
32
+ Authorization: `Bearer ${this.apiKey}`,
33
+ ...headers,
34
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
35
+ },
36
+ body: body === void 0 ? void 0 : JSON.stringify(body),
37
+ signal: AbortSignal.timeout(timeoutMs)
38
+ });
39
+ const text = await res.text();
40
+ let payload;
41
+ try {
42
+ payload = text ? JSON.parse(text) : null;
43
+ } catch {
44
+ payload = text;
45
+ }
46
+ if (!res.ok) {
47
+ let message = payload?.error || `${method} ${path} failed (${res.status})`;
48
+ if (message === "Invalid API key") message += ` for ${this.baseUrl}. Check OYA_API_KEY: a value exported in your shell beats .env.`;
49
+ throw new OyaError(message, res.status, payload);
50
+ }
51
+ return payload;
52
+ }
53
+ };
54
+ var NAVIGATE_TIMEOUT_MS = 12e4;
55
+ var Browser = class {
56
+ constructor(http, info, autoCaptcha) {
57
+ this.http = http;
58
+ this.autoCaptcha = autoCaptcha;
59
+ this.id = info.id;
60
+ this.provider = info.provider;
61
+ this.persona = info.persona;
62
+ this.cdpUrl = info.cdpUrl;
63
+ }
64
+ http;
65
+ autoCaptcha;
66
+ id;
67
+ provider;
68
+ persona;
69
+ /** Point Playwright, Puppeteer or browser-use here. */
70
+ cdpUrl;
71
+ async command(action, params = {}, timeoutMs) {
72
+ const result = await this.http.request(
73
+ "POST",
74
+ `/api/browsers/${this.id}/command`,
75
+ { action, params },
76
+ timeoutMs
77
+ );
78
+ if (result.ok === false) throw new OyaError(result.error || `${action} failed`, 422, result);
79
+ return result.data;
80
+ }
81
+ async goto(url) {
82
+ await this.command("navigate", { url }, NAVIGATE_TIMEOUT_MS);
83
+ if (this.autoCaptcha) {
84
+ const result = await this.solveCaptcha();
85
+ if (result.present && !result.solved && !result.invisible) throw new OyaError(result.error || "CAPTCHA needs attention. Call solveCaptcha() again or open the live view.", 409, result);
86
+ }
87
+ }
88
+ /** The page as markdown plus numbered elements to act on. */
89
+ async analyze() {
90
+ return this.command("analyze");
91
+ }
92
+ /** Only the visible elements, which is what an agent almost always wants. */
93
+ async elements() {
94
+ return (await this.analyze()).elements.filter((e) => e.visible);
95
+ }
96
+ async click(elementId) {
97
+ const id = this.elementId(elementId);
98
+ await this.command("click", { element_id: id, selector: `[data-ac-id="${id}"]` });
99
+ }
100
+ async type(elementId, text) {
101
+ const id = this.elementId(elementId);
102
+ return this.command("type", { element_id: id, selector: `[data-ac-id="${id}"]`, text });
103
+ }
104
+ elementId(value) {
105
+ const id = Number(value);
106
+ if (typeof value !== "number" && typeof value !== "string" || value === "" || !Number.isInteger(id) || id < 0) {
107
+ throw new OyaError("Use a numeric element id from browser.analyze().", 400, null);
108
+ }
109
+ return id;
110
+ }
111
+ async pressKey(key) {
112
+ await this.command("press_key", { key });
113
+ }
114
+ /** `at` aims the wheel at an inner scroller (a results panel, a chat pane) instead of the page. */
115
+ async scroll(direction, amount, at) {
116
+ await this.command("scroll", at ? { direction, amount: amount ?? 500, ...at, smooth: false } : { direction, amount });
117
+ }
118
+ async waitFor(selector, timeout = 3e4) {
119
+ await this.command("wait", { selector, timeout }, timeout + 5e3);
120
+ }
121
+ /** A `data:image/…;base64,` URL. PNG or JPEG depending on the driver. */
122
+ async screenshot() {
123
+ const data = await this.command("screenshot");
124
+ return data.screenshot;
125
+ }
126
+ async url() {
127
+ const tabs = await this.tabs();
128
+ return tabs.find((t) => t.active)?.url || "";
129
+ }
130
+ async tabs() {
131
+ const data = await this.command("list_tabs");
132
+ return data.tabs || [];
133
+ }
134
+ async openTab(url) {
135
+ const data = await this.command("open_tab", { url }, NAVIGATE_TIMEOUT_MS);
136
+ return data.tab_id;
137
+ }
138
+ async switchTab(tabId) {
139
+ await this.command("switch_tab", { tab_id: tabId });
140
+ }
141
+ async closeTab(tabId) {
142
+ await this.command("close_tab", { tab_id: tabId });
143
+ }
144
+ /**
145
+ * Detect and clear a CAPTCHA. Providers that solve natively are left to do
146
+ * it; everything else goes to the configured solver.
147
+ */
148
+ solveCaptcha() {
149
+ return this.http.request("POST", `/api/browsers/${this.id}/captcha`, {}, 18e4);
150
+ }
151
+ /**
152
+ * Answer an MFA prompt with the persona's configured factor. When nothing can
153
+ * answer it, `liveViewUrl` is where a person finishes by hand.
154
+ */
155
+ async completeMfa() {
156
+ const result = await this.http.request("POST", `/api/browsers/${this.id}/mfa`, {}, 18e4);
157
+ if (result.liveViewUrl) result.liveViewUrl = new URL(result.liveViewUrl, this.http.baseUrl).href;
158
+ return result;
159
+ }
160
+ /** Natural-language control, using this key's configured model. */
161
+ async ask(prompt) {
162
+ const res = await this.http.request(
163
+ "POST",
164
+ `/api/browsers/${this.id}/chat`,
165
+ { messages: [{ role: "user", content: prompt }] },
166
+ 6e5
167
+ );
168
+ return res.text;
169
+ }
170
+ /**
171
+ * Watch it work: an SSE stream of JPEG frames. EventSource cannot set
172
+ * headers, so the key travels as a query parameter — treat the URL itself as
173
+ * a credential.
174
+ */
175
+ liveViewUrl() {
176
+ return `${this.http.baseUrl}/api/live/${this.id}?key=${encodeURIComponent(this.http.apiKey)}`;
177
+ }
178
+ /** Counters, health and the last 50 things this browser did. */
179
+ status() {
180
+ return this.http.request("GET", `/api/browsers/${this.id}`);
181
+ }
182
+ /**
183
+ * Stop it, whatever it is: a cloud sandbox is destroyed so billing ends, a
184
+ * CDP session is handed back to its provider, a desktop browser disconnects.
185
+ */
186
+ stop() {
187
+ return this.http.request("POST", `/api/browsers/${this.id}/stop`, {}, 6e4);
188
+ }
189
+ /** `await using browser = await oya.browser.start()` stops it however the block exits, errors included. */
190
+ async [Symbol.asyncDispose]() {
191
+ await this.stop();
192
+ }
193
+ /** @deprecated use stop() — close() only dropped the socket, and a cloud browser redialled. */
194
+ async close() {
195
+ await this.stop();
196
+ }
197
+ };
198
+ var DEFAULT_BASE_URL = "https://browser.getoya.ai";
199
+ var READY_POLL_MS = 2e3;
200
+ var env = (name) => globalThis.process?.env?.[name];
201
+ var Oya = class {
202
+ http;
203
+ constructor(options = {}) {
204
+ const apiKey = options.apiKey || env("OYA_API_KEY");
205
+ if (!apiKey) {
206
+ throw new Error("No API key. Pass { apiKey } or set OYA_API_KEY \u2014 run `oya login` to get one.");
207
+ }
208
+ const baseUrl = (options.baseUrl || env("OYA_BASE_URL") || DEFAULT_BASE_URL).replace(/\/+$/, "");
209
+ const fetchImpl = options.fetch || globalThis.fetch;
210
+ if (!fetchImpl) throw new Error("No fetch available \u2014 pass { fetch } or use Node 18+.");
211
+ this.http = new Http(baseUrl, apiKey, options.timeoutMs ?? 6e4, fetchImpl.bind(globalThis));
212
+ }
213
+ browser = {
214
+ /** Start a browser and wait until it can take commands. */
215
+ start: async (options = {}) => {
216
+ const started = await this.http.request("POST", "/api/browsers/start", {
217
+ profile: options.profile || options.persona,
218
+ provider: options.provider,
219
+ wsUrl: options.wsUrl,
220
+ name: options.name,
221
+ queueMs: options.queueMs,
222
+ priority: options.priority,
223
+ budgetUsd: options.budgetUsd,
224
+ governed: options.governed,
225
+ policy: options.policy
226
+ }, 12e4, { "Idempotency-Key": options.idempotencyKey || globalThis.crypto.randomUUID() });
227
+ if (started.status === "starting") {
228
+ await this.waitUntilConnected(started.id, options.readyTimeoutMs ?? 12e4 + (options.queueMs || 0));
229
+ const connected = await this.http.request("GET", `/api/browsers/${encodeURIComponent(started.id)}`);
230
+ started.cdpUrl = connected.cdpUrl;
231
+ }
232
+ return new Browser(this.http, started, options.captcha === "auto");
233
+ },
234
+ /** Reattach to a browser that is already running. */
235
+ get: async (id) => {
236
+ const found = await this.http.request("GET", `/api/browsers/${encodeURIComponent(id)}`);
237
+ return new Browser(this.http, {
238
+ id: found.id,
239
+ provider: found.provider || "cdp",
240
+ persona: found.persona || "default",
241
+ status: "ready",
242
+ cdpUrl: found.cdpUrl
243
+ }, false);
244
+ },
245
+ list: () => this.http.request("GET", "/api/browsers"),
246
+ /** Stop some (`ids`) or every browser on this key. Each reports separately. */
247
+ stop: (ids) => this.http.request("POST", "/api/browsers/stop", ids === "all" ? { all: true } : { ids }, 12e4),
248
+ stopAll: async () => (await this.browser.stop("all")).stopped
249
+ };
250
+ /** Durable operational controls, including disconnected and cleanup-pending sessions. */
251
+ control = {
252
+ overview: () => this.http.request("GET", "/api/control"),
253
+ sessions: () => this.http.request("GET", "/api/control/sessions"),
254
+ session: (id) => this.http.request("GET", `/api/control/sessions/${encodeURIComponent(id)}`),
255
+ settings: (changes) => this.http.request("PATCH", "/api/control/project", changes),
256
+ cancel: (id) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/cancel`, {}),
257
+ stop: (id, force = false) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/stop`, { force }),
258
+ takeover: (id, action) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/control`, { action }),
259
+ input: (id, action, params) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/input`, { action, params }),
260
+ recover: (id, replace = false) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/recover`, { replace }),
261
+ ticket: (id) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/ticket`, {}),
262
+ events: (after = 0) => this.http.request("GET", `/api/control/events?after=${after}`),
263
+ createCredential: (options) => this.http.request("POST", "/api/control/credentials", options),
264
+ revokeCredential: (id) => this.http.request("DELETE", `/api/control/credentials/${encodeURIComponent(id)}`),
265
+ members: () => this.http.request("GET", "/api/control/members"),
266
+ inviteMember: (role = "operator") => this.http.request("POST", "/api/control/members/invite", { role }),
267
+ removeMember: (userId) => this.http.request("DELETE", `/api/control/members/${encodeURIComponent(userId)}`),
268
+ createWebhook: (url, types = []) => this.http.request("POST", "/api/control/webhooks", { url, types }),
269
+ removeWebhook: (id) => this.http.request("DELETE", `/api/control/webhooks/${encodeURIComponent(id)}`),
270
+ replayDelivery: (id) => this.http.request("POST", `/api/control/deliveries/${encodeURIComponent(id)}/replay`, {})
271
+ };
272
+ personas = {
273
+ list: async () => (await this.http.request("GET", "/api/personas")).personas,
274
+ get: (id) => this.http.request("GET", `/api/personas/${id}`),
275
+ /**
276
+ * Create an identity. The device — platform, timezone, locale — is chosen
277
+ * here and fixed for its life; `preview()` shows what a choice produces.
278
+ */
279
+ create: (options = {}) => this.http.request("POST", "/api/personas", options),
280
+ /** Name, concurrency cap and proxy hint. Never the device — clone for that. */
281
+ update: (id, changes) => this.http.request("PUT", `/api/personas/${id}`, changes),
282
+ /** A new persona of the same kind of device: same choices, fresh identity, empty jar. */
283
+ clone: (id, options = {}) => this.http.request("POST", `/api/personas/${id}/clone`, options),
284
+ /** The fingerprint these choices would produce. Persists nothing. */
285
+ preview: async (prefs = {}) => (await this.http.request("POST", "/api/personas/preview", { prefs })).fingerprint,
286
+ /** Platforms, and the timezones and locales each may coherently claim. */
287
+ options: () => this.http.request("GET", "/api/personas/options"),
288
+ /** Pin the persona to one of your proxies, or `null` to let assignment happen at connect. */
289
+ pinProxy: (id, proxyId) => this.http.request("PUT", `/api/personas/${id}/proxy`, { proxyId }),
290
+ remove: async (id) => {
291
+ await this.http.request("DELETE", `/api/personas/${id}`);
292
+ },
293
+ /** Store the second factor for this identity. Sealed at rest, never read back. */
294
+ setMfa: (id, config) => this.http.request("PUT", `/api/personas/${id}/mfa`, config),
295
+ clearMfa: async (id) => {
296
+ await this.http.request("DELETE", `/api/personas/${id}/mfa`);
297
+ }
298
+ };
299
+ /** This key's settings: LLM credentials, browser provider, solver. */
300
+ config = {
301
+ get: () => this.http.request("GET", "/api/config"),
302
+ set: (values) => this.http.request("POST", "/api/config", values)
303
+ };
304
+ /** Saved profiles. `personas` is retained as an alias for existing clients. */
305
+ profiles = this.personas;
306
+ usage() {
307
+ return this.http.request("GET", "/api/usage");
308
+ }
309
+ async waitUntilConnected(id, timeoutMs) {
310
+ const deadline = Date.now() + timeoutMs;
311
+ while (Date.now() < deadline) {
312
+ const all = await this.browser.list();
313
+ if (all.some((b) => b.id === id && b.health !== "dead")) return;
314
+ try {
315
+ const session = await this.control.session(id);
316
+ if (["failed", "stopped", "unknown_outcome"].includes(session.state)) throw new OyaError(`Browser creation ended in ${session.state}`, 409, session);
317
+ } catch (e) {
318
+ if (!(e instanceof OyaError) || e.status !== 404) throw e;
319
+ }
320
+ await new Promise((r) => setTimeout(r, READY_POLL_MS));
321
+ }
322
+ throw new OyaError(`Browser ${id} did not come up within ${Math.round(timeoutMs / 1e3)}s`, 504, null);
323
+ }
324
+ };
325
+
326
+ // src/config.ts
327
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
328
+ import { homedir } from "os";
329
+ import { join, dirname } from "path";
330
+ var FILE = join(process.env.OYA_CONFIG_HOME || join(homedir(), ".oya"), "config.json");
331
+ function load() {
332
+ try {
333
+ return JSON.parse(readFileSync(FILE, "utf8"));
334
+ } catch {
335
+ return {};
336
+ }
337
+ }
338
+ function save(values) {
339
+ const merged = { ...load(), ...values };
340
+ mkdirSync(dirname(FILE), { recursive: true });
341
+ writeFileSync(FILE, JSON.stringify(merged, null, 2), { mode: 384 });
342
+ chmodSync(FILE, 384);
343
+ }
344
+ var configPath = FILE;
345
+ function resolved() {
346
+ const saved = load();
347
+ return {
348
+ apiKey: process.env.OYA_API_KEY || saved.apiKey || "",
349
+ baseUrl: (process.env.OYA_BASE_URL || saved.baseUrl || "https://browser.getoya.ai").replace(/\/+$/, "")
350
+ };
351
+ }
352
+
353
+ // src/prompt.ts
354
+ import { createInterface } from "readline/promises";
355
+ import { stdin, stdout } from "process";
356
+ async function ask(question, fallback = "") {
357
+ const rl = createInterface({ input: stdin, output: stdout });
358
+ try {
359
+ const answer = (await rl.question(fallback ? `${question} [${fallback}] ` : `${question} `)).trim();
360
+ return answer || fallback;
361
+ } finally {
362
+ rl.close();
363
+ }
364
+ }
365
+ async function askSecret(question) {
366
+ const rl = createInterface({ input: stdin, output: stdout, terminal: true });
367
+ const asAny = rl;
368
+ asAny._writeToOutput = (s) => {
369
+ asAny.output.write(s.includes(question) ? s : "*");
370
+ };
371
+ try {
372
+ const answer = (await rl.question(`${question} `)).trim();
373
+ stdout.write("\n");
374
+ return answer;
375
+ } finally {
376
+ rl.close();
377
+ }
378
+ }
379
+ async function choose(question, options) {
380
+ console.log(`
381
+ ${question}`);
382
+ options.forEach((o, i) => console.log(` ${i + 1}) ${o.label}${o.note ? ` \u2014 ${o.note}` : ""}`));
383
+ while (true) {
384
+ const answer = await ask(" choice:", "1");
385
+ const index = Number(answer) - 1;
386
+ if (options[index]) return options[index].id;
387
+ const byId = options.find((o) => o.id === answer);
388
+ if (byId) return byId.id;
389
+ console.log(" Pick one of the numbers above.");
390
+ }
391
+ }
392
+
393
+ // src/index.ts
394
+ var HELP = `oya \u2014 thousands of browsers, one API
395
+
396
+ oya login Save an API key for this machine
397
+ oya init Set your model, browser provider and sign-ins
398
+ oya start [--persona auto] Start a browser and print its id
399
+ oya goto <url> [--id <id>] Navigate (defaults to the newest browser)
400
+ oya ask "<prompt>" [--id <id>] Drive it in plain language
401
+ oya ls List running browsers
402
+ oya rm <id> | --all Stop browsers
403
+ oya personas Identities: fingerprint + cookies + proxy
404
+ oya personas new [name] --platform Win32|MacIntel|Linux --tz <zone> --locale <l> --max <n>
405
+ oya personas edit <id> --name <n> --max <n> --geo <cc>
406
+ oya personas clone|rm <id> A new device of the same kind \xB7 delete
407
+ oya status [--id <id>] Health, counters and what it has been doing
408
+ oya open [--id <id>] Open the live view in your browser
409
+ oya config [key=value ...] Show or change this key's settings
410
+ oya control Durable project overview
411
+ oya sessions [id] All sessions, including pending cleanup
412
+ oya stop <id> --force Stop despite a profile-save error, or reconcile
413
+ oya takeover <id> Acquire human control
414
+ oya release <id> Release human control, leaving the agent paused
415
+ oya resume <id> Acknowledge agent resume
416
+ oya events [--after <cursor>] Read durable lifecycle events
417
+ oya project <settings-json> Update limits, rate cards and retention
418
+ oya start --governed --provider oya-selfhosted [--queue-ms 30000]
419
+ [--budget-usd 5] [--policy JSON] [--idempotency-key ID]
420
+ oya cancel <id> Cancel queued or provisioning work
421
+ oya recover <id> [--replace] Explicitly recover or replace a session
422
+ oya members [invite|remove] List members, invite, or remove a user
423
+ oya credential new [--role viewer|operator|administrator]
424
+ oya credential revoke <id> Revoke a service credential
425
+ oya webhook new <https-url> Register a signed event webhook
426
+ oya webhook remove <id> Disable a webhook
427
+ oya webhook replay <id> Replay a delivery
428
+ oya usage What this key has spent
429
+ oya stealth-test [--live] Score this deployment against bot detectors
430
+
431
+ Options: --url <control plane> --key <api key> --json
432
+ Config: ${configPath}
433
+ `;
434
+ function parse(argv) {
435
+ const [command2 = "help", ...rest] = argv;
436
+ const args2 = [];
437
+ const flags2 = {};
438
+ for (let i = 0; i < rest.length; i++) {
439
+ const token = rest[i];
440
+ if (!token.startsWith("--")) {
441
+ args2.push(token);
442
+ continue;
443
+ }
444
+ const name = token.slice(2);
445
+ const next = rest[i + 1];
446
+ if (next === void 0 || next.startsWith("--")) flags2[name] = true;
447
+ else {
448
+ flags2[name] = next;
449
+ i++;
450
+ }
451
+ }
452
+ return { command: command2, args: args2, flags: flags2 };
453
+ }
454
+ var flagStr = (flags2, name) => typeof flags2[name] === "string" ? flags2[name] : void 0;
455
+ function client(flags2) {
456
+ const saved = resolved();
457
+ const apiKey = flagStr(flags2, "key") || saved.apiKey;
458
+ const baseUrl = flagStr(flags2, "url") || saved.baseUrl;
459
+ if (!apiKey) {
460
+ console.error("No API key. Run `oya login`, or pass --key / set OYA_API_KEY.");
461
+ process.exit(1);
462
+ }
463
+ return new Oya({ apiKey, baseUrl });
464
+ }
465
+ var out = (flags2, value, human) => {
466
+ if (flags2.json) console.log(JSON.stringify(value, null, 2));
467
+ else human();
468
+ };
469
+ async function targetBrowser(oya, flags2) {
470
+ const id = flagStr(flags2, "id");
471
+ if (id) return oya.browser.get(id);
472
+ const all = await oya.browser.list();
473
+ if (!all.length) {
474
+ console.error("No browsers running. Start one with `oya start`.");
475
+ process.exit(1);
476
+ }
477
+ return oya.browser.get(all[all.length - 1].id);
478
+ }
479
+ async function cmdLogin(flags2) {
480
+ const current = resolved();
481
+ let apiKey = flagStr(flags2, "key") || "";
482
+ const baseUrl = (flagStr(flags2, "url") || (apiKey ? current.baseUrl : await ask("Control plane URL:", current.baseUrl))).replace(/\/+$/, "");
483
+ const how = apiKey ? "paste" : await choose("How do you want to authenticate?", [
484
+ { id: "paste", label: "Paste an API key", note: "from the dashboard, or a self-hosted key" },
485
+ { id: "password", label: "Sign in with email and password", note: "mints a new key for this machine" }
486
+ ]);
487
+ if (!apiKey && how === "paste") apiKey = await askSecret("API key:");
488
+ if (!apiKey && how === "password") {
489
+ const email = await ask("Email:");
490
+ const password = await askSecret("Password:");
491
+ const res = await fetch(`${baseUrl}/api/auth/login`, {
492
+ method: "POST",
493
+ headers: { "Content-Type": "application/json" },
494
+ body: JSON.stringify({ email, password })
495
+ });
496
+ const session = await res.json();
497
+ if (!res.ok || !session.access_token) throw new Error(session.error || "Sign-in failed");
498
+ const minted = await fetch(`${baseUrl}/api/auth/keys`, {
499
+ method: "POST",
500
+ headers: { Authorization: `Bearer ${session.access_token}`, "Content-Type": "application/json" },
501
+ body: JSON.stringify({ label: "CLI" })
502
+ });
503
+ const created = await minted.json();
504
+ if (!minted.ok || !created.key) throw new Error(created.error || "Could not create an API key");
505
+ apiKey = created.key;
506
+ console.log(' Created a new API key labelled "CLI".');
507
+ }
508
+ if (!apiKey) throw new Error("No API key given");
509
+ const check = await fetch(`${baseUrl}/api/config`, { headers: { Authorization: `Bearer ${apiKey}` } });
510
+ if (!check.ok) throw new Error(`That key was rejected by ${baseUrl} (${check.status})`);
511
+ save({ apiKey, baseUrl });
512
+ console.log(`
513
+ \u2705 Signed in to ${baseUrl}. Saved to ${configPath}`);
514
+ console.log(" Next: `oya init` to pick your model and browser provider.");
515
+ }
516
+ async function cmdInit(flags2) {
517
+ const oya = client(flags2);
518
+ const current = await oya.config.get();
519
+ console.log("\n\u2500\u2500 1. Your model \u2500\u2500");
520
+ const llm = await choose("Which LLM should agents use?", [
521
+ { id: "anthropic", label: "Claude (Anthropic)" },
522
+ { id: "openai", label: "OpenAI" },
523
+ { id: "skip", label: "Skip", note: current.has_openai_key ? "keep what is configured" : "no agent control" }
524
+ ]);
525
+ const updates = {};
526
+ if (llm !== "skip") {
527
+ updates.llm_provider = llm;
528
+ const key = await askSecret(`${llm === "anthropic" ? "Anthropic" : "OpenAI"} API key:`);
529
+ if (key) updates.openai_api_key = key;
530
+ const model = await ask("Default model:", llm === "anthropic" ? "claude-sonnet-4-5" : "gpt-4o-mini");
531
+ if (model) updates.chat_model = model;
532
+ }
533
+ console.log("\n\u2500\u2500 2. Where your browsers run \u2500\u2500");
534
+ const provider = await choose("Browser provider:", current.providers.map((p) => ({
535
+ id: p.id,
536
+ label: p.label,
537
+ note: p.needs.length ? p.configured ? "configured" : "needs an API key" : void 0
538
+ })));
539
+ updates.browser_provider = provider;
540
+ const needs = current.providers.find((p) => p.id === provider)?.needs || [];
541
+ for (const field of needs) {
542
+ const value = await askSecret(`${field.replace(/_/g, " ")}:`);
543
+ if (value) updates[field] = value;
544
+ }
545
+ console.log("\n\u2500\u2500 3. CAPTCHAs \u2500\u2500");
546
+ const solver = await choose("Solve CAPTCHAs automatically?", [
547
+ { id: "", label: "No", note: "providers that solve natively still will" },
548
+ { id: "capsolver", label: "Yes, via CapSolver" }
549
+ ]);
550
+ updates.captcha_solver = solver;
551
+ if (solver) {
552
+ const key = await askSecret("Solver API key:");
553
+ if (key) updates.captcha_api_key = key;
554
+ }
555
+ updates.onboarded = "true";
556
+ await oya.config.set(updates);
557
+ console.log("\n\u2705 Saved against your API key.");
558
+ if (provider === "oya-cloud" || provider === "oya-selfhosted") {
559
+ console.log("\n\u2500\u2500 4. Sign in once, on your own machine \u2500\u2500");
560
+ console.log(" Your remote browsers reuse the cookies from a desktop sign-in, so agents");
561
+ console.log(" arrive already logged in \u2014 as the same identity, from the same fingerprint.");
562
+ console.log(` Download the desktop browser: ${resolved().baseUrl}/downloads`);
563
+ }
564
+ console.log("\n Then: oya start && oya goto https://example.com");
565
+ }
566
+ async function cmdStart(flags2) {
567
+ const oya = client(flags2);
568
+ const browser = await oya.browser.start({
569
+ persona: flagStr(flags2, "persona") || "default",
570
+ captcha: flags2.captcha === false ? "off" : "auto",
571
+ provider: flagStr(flags2, "provider"),
572
+ wsUrl: flagStr(flags2, "ws-url"),
573
+ name: flagStr(flags2, "name"),
574
+ idempotencyKey: flagStr(flags2, "idempotency-key"),
575
+ queueMs: flagStr(flags2, "queue-ms") === void 0 ? void 0 : Number(flagStr(flags2, "queue-ms")),
576
+ budgetUsd: flagStr(flags2, "budget-usd") === void 0 ? void 0 : Number(flagStr(flags2, "budget-usd")),
577
+ governed: flags2.governed === true,
578
+ priority: flagStr(flags2, "priority"),
579
+ policy: flagStr(flags2, "policy") ? JSON.parse(flagStr(flags2, "policy")) : void 0
580
+ });
581
+ out(flags2, { id: browser.id, provider: browser.provider, persona: browser.persona, cdpUrl: browser.cdpUrl }, () => {
582
+ console.log(`\u2705 ${browser.id}`);
583
+ console.log(` provider: ${browser.provider} persona: ${browser.persona}`);
584
+ if (browser.cdpUrl) console.log(` cdp: ${browser.cdpUrl}`);
585
+ });
586
+ }
587
+ async function cmdGoto(args2, flags2) {
588
+ const url = args2[0];
589
+ if (!url) throw new Error("Usage: oya goto <url>");
590
+ const oya = client(flags2);
591
+ const browser = await targetBrowser(oya, flags2);
592
+ await browser.goto(url);
593
+ console.log(`\u2705 ${browser.id} \u2192 ${url}`);
594
+ }
595
+ async function cmdAsk(args2, flags2) {
596
+ const prompt = args2.join(" ");
597
+ if (!prompt) throw new Error('Usage: oya ask "find the pricing page"');
598
+ const oya = client(flags2);
599
+ const browser = await targetBrowser(oya, flags2);
600
+ console.log(await browser.ask(prompt));
601
+ }
602
+ async function cmdLs(flags2) {
603
+ const all = await client(flags2).browser.list();
604
+ out(flags2, all, () => {
605
+ if (!all.length) return console.log("No browsers running.");
606
+ const dot = { ok: "\u25CF", stale: "\u25D0", errors: "\u2717", dead: "\u25CB" };
607
+ for (const b of all) {
608
+ console.log(`${dot[b.health] || "\xB7"} ${b.id} ${(b.provider || "oya").padEnd(14)} ${(b.personaName || b.persona || "default").padEnd(14)} ${b.name.padEnd(18)} ${b.commands}\xB7${b.errors} ${b.currentUrl.replace(/^https?:\/\//, "").slice(0, 40)}`);
609
+ }
610
+ console.log(`
611
+ ${all.length} running.`);
612
+ });
613
+ }
614
+ async function cmdRm(args2, flags2) {
615
+ const oya = client(flags2);
616
+ if (!flags2.all && !args2.length) throw new Error("Usage: oya rm <id>\u2026 | oya rm --all");
617
+ const r = await oya.browser.stop(flags2.all ? "all" : args2);
618
+ for (const x of r.results) {
619
+ const note = x.sandboxRemoved === true ? " (sandbox destroyed)" : x.sandboxRemoved === false ? " \u2014 sandbox NOT removed, check Daytona" : "";
620
+ console.log(`${x.ok ? "\u2705" : "\u2717"} ${x.id}${note}${x.error ? ` ${x.error}` : ""}`);
621
+ }
622
+ console.log(`stopped ${r.stopped}`);
623
+ }
624
+ async function cmdStatus(flags2) {
625
+ const oya = client(flags2);
626
+ const browser = await targetBrowser(oya, flags2);
627
+ const s = await browser.status();
628
+ out(flags2, s, () => {
629
+ console.log(`${s.name} ${s.id}`);
630
+ console.log(` ${s.health} \xB7 ${s.provider || "oya"} \xB7 persona ${s.personaName || s.persona || "default"}`);
631
+ console.log(` ${s.commands} commands \xB7 ${s.errors} errors \xB7 ${s.pending} in flight \xB7 at ${s.currentUrl || "\u2014"}`);
632
+ if (s.lastError) console.log(` last error: ${s.lastError}`);
633
+ if (s.activity.length) {
634
+ console.log(" recent:");
635
+ for (const a of s.activity.slice(0, 10)) console.log(` ${a.ok ? "\xB7" : "\u2717"} ${a.action.padEnd(18)} ${a.summary}${a.error ? ` \u2014 ${a.error}` : ""} ${a.ms}ms`);
636
+ }
637
+ });
638
+ }
639
+ async function cmdPersonas(args2, flags2) {
640
+ const oya = client(flags2);
641
+ const [sub, ...rest] = args2;
642
+ if (sub === "new" || sub === "create") {
643
+ const platformAlias = {
644
+ win32: "Win32",
645
+ windows: "Win32",
646
+ win: "Win32",
647
+ macintel: "MacIntel",
648
+ mac: "MacIntel",
649
+ macos: "MacIntel",
650
+ linux: "Linux x86_64",
651
+ "linux x86_64": "Linux x86_64"
652
+ };
653
+ const platformFlag = flagStr(flags2, "platform");
654
+ const prefs = {
655
+ ...platformFlag ? { platform: platformAlias[platformFlag.toLowerCase()] || platformFlag } : {},
656
+ ...flagStr(flags2, "tz") ? { timezone: flagStr(flags2, "tz") } : {},
657
+ ...flagStr(flags2, "locale") ? { locale: flagStr(flags2, "locale") } : {}
658
+ };
659
+ if (flags2.preview) {
660
+ const fp = await oya.personas.preview(prefs);
661
+ return out(flags2, fp, () => console.log(`${fp.platform} \xB7 ${fp.timezone} \xB7 ${fp.locale} \xB7 ${fp.screen} \xB7 ${fp.webgl}`));
662
+ }
663
+ const created = await oya.personas.create({
664
+ name: flagStr(flags2, "name") || rest[0],
665
+ prefs,
666
+ proxy: flagStr(flags2, "geo") ? { geo: flagStr(flags2, "geo") } : void 0,
667
+ maxConcurrent: flagStr(flags2, "max") ? Number(flagStr(flags2, "max")) : void 0
668
+ });
669
+ return out(flags2, created, () => console.log(`\u2705 ${created.id} ${created.name} ${created.fingerprint.platform} \xB7 ${created.fingerprint.timezone}`));
670
+ }
671
+ if (sub === "edit") {
672
+ if (!rest[0]) throw new Error("Usage: oya personas edit <id> --name <n> --max <n> --geo <cc>");
673
+ const updated = await oya.personas.update(rest[0], {
674
+ ...flagStr(flags2, "name") ? { name: flagStr(flags2, "name") } : {},
675
+ ...flagStr(flags2, "max") ? { maxConcurrent: flagStr(flags2, "max") === "none" ? null : Number(flagStr(flags2, "max")) } : {},
676
+ ...flagStr(flags2, "geo") ? { proxy: { geo: flagStr(flags2, "geo") } } : {}
677
+ });
678
+ return out(flags2, updated, () => console.log(`\u2705 ${updated.id} ${updated.name} cap ${updated.maxConcurrent ?? "\u221E"}`));
679
+ }
680
+ if (sub === "clone") {
681
+ if (!rest[0]) throw new Error("Usage: oya personas clone <id> [--name <n>]");
682
+ const c = await oya.personas.clone(rest[0], { name: flagStr(flags2, "name") });
683
+ return out(flags2, c, () => console.log(`\u2705 ${c.id} ${c.name} ${c.fingerprint.platform} \xB7 ${c.fingerprint.timezone} (new identity, same kind of device)`));
684
+ }
685
+ if (sub === "rm" || sub === "delete") {
686
+ if (!rest[0]) throw new Error("Usage: oya personas rm <id>");
687
+ await oya.personas.remove(rest[0]);
688
+ return console.log(`\u2705 removed ${rest[0]}`);
689
+ }
690
+ const all = await oya.personas.list();
691
+ out(flags2, all, () => {
692
+ if (!all.length) return console.log("No personas yet \u2014 `oya personas new`.");
693
+ for (const p of all) {
694
+ const cap = p.maxConcurrent === null ? "\u221E" : String(p.maxConcurrent);
695
+ console.log(`${p.id} ${p.name.padEnd(20)} ${p.activeBrowsers}/${cap} running ${p.fingerprint.platform} \xB7 ${p.fingerprint.timezone}${p.exit ? ` via ${p.exit.label}` : p.proxy?.geo ? ` geo ${p.proxy.geo}` : ""}${p.mfa?.configured ? ` mfa:${p.mfa.type}` : ""}${p.isDefault ? " (default)" : ""}`);
696
+ }
697
+ });
698
+ }
699
+ async function cmdOpen(flags2) {
700
+ const browser = await targetBrowser(client(flags2), flags2);
701
+ const url = browser.liveViewUrl();
702
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
703
+ spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
704
+ console.log(`Opening ${browser.id}`);
705
+ }
706
+ async function cmdConfig(args2, flags2) {
707
+ const oya = client(flags2);
708
+ if (!args2.length) {
709
+ const current = await oya.config.get();
710
+ return out(flags2, current, () => console.log(JSON.stringify(current, null, 2)));
711
+ }
712
+ const updates = {};
713
+ for (const pair of args2) {
714
+ const index = pair.indexOf("=");
715
+ if (index < 1) throw new Error(`Expected key=value, got "${pair}"`);
716
+ updates[pair.slice(0, index)] = pair.slice(index + 1);
717
+ }
718
+ await oya.config.set(updates);
719
+ console.log(`\u2705 updated ${Object.keys(updates).join(", ")}`);
720
+ }
721
+ async function cmdUsage(flags2) {
722
+ const usage = await client(flags2).usage();
723
+ console.log(JSON.stringify(usage, null, 2));
724
+ }
725
+ function cmdStealthTest(flags2) {
726
+ return new Promise((resolve, reject) => {
727
+ const child = spawn("node", ["test-stealth.js", ...flags2.live ? ["--live"] : []], {
728
+ cwd: process.env.OYA_SERVER_DIR || "server",
729
+ stdio: "inherit"
730
+ });
731
+ child.on("error", () => reject(new Error(
732
+ "Could not find the stealth harness. Run it from a checkout, or set OYA_SERVER_DIR."
733
+ )));
734
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`stealth test exited ${code}`)));
735
+ });
736
+ }
737
+ var { command, args, flags } = parse(process.argv.slice(2));
738
+ try {
739
+ if (["control", "sessions", "takeover", "release", "resume", "events", "project", "credential", "webhook", "cancel", "recover", "members"].includes(command) || command === "stop" && flags.force === true) {
740
+ const c = client(flags).control;
741
+ const required = (i = 0) => {
742
+ if (!args[i]) throw new Error("Missing argument; run oya help");
743
+ return args[i];
744
+ };
745
+ let result;
746
+ switch (command) {
747
+ case "control":
748
+ result = await c.overview();
749
+ break;
750
+ case "sessions":
751
+ result = args[0] ? await c.session(args[0]) : await c.sessions();
752
+ break;
753
+ case "cancel":
754
+ result = await c.cancel(required());
755
+ break;
756
+ case "recover":
757
+ result = await c.recover(required(), flags.replace === true);
758
+ break;
759
+ case "members":
760
+ if (!args[0]) result = await c.members();
761
+ else if (args[0] === "remove") result = await c.removeMember(required(1));
762
+ else if (args[0] === "invite") {
763
+ const role = flagStr(flags, "role") || "operator";
764
+ if (!["viewer", "operator", "administrator"].includes(role)) throw new Error("Invalid role");
765
+ result = await c.inviteMember(role);
766
+ } else throw new Error("Use members, members invite, or members remove");
767
+ break;
768
+ case "stop":
769
+ result = await c.stop(required(), flags.force === true);
770
+ break;
771
+ case "takeover":
772
+ case "release":
773
+ case "resume":
774
+ result = await c.takeover(required(), command === "takeover" ? "acquire" : command);
775
+ break;
776
+ case "events":
777
+ result = await c.events(Number(flagStr(flags, "after") || 0));
778
+ break;
779
+ case "project":
780
+ result = await c.settings(JSON.parse(required()));
781
+ break;
782
+ case "credential":
783
+ if (required() === "revoke") result = await c.revokeCredential(required(1));
784
+ else if (args[0] === "new") {
785
+ const role = flagStr(flags, "role") || "operator";
786
+ if (!["viewer", "operator", "administrator"].includes(role)) throw new Error("Invalid role");
787
+ result = await c.createCredential({ role, label: flagStr(flags, "label") });
788
+ } else throw new Error("Use credential new or credential revoke");
789
+ break;
790
+ case "webhook":
791
+ if (required() === "new") result = await c.createWebhook(required(1));
792
+ else if (args[0] === "remove") result = await c.removeWebhook(required(1));
793
+ else if (args[0] === "replay") result = await c.replayDelivery(required(1));
794
+ else throw new Error("Use webhook new, remove, or replay");
795
+ break;
796
+ }
797
+ console.log(JSON.stringify(result, null, 2));
798
+ } else switch (command) {
799
+ case "login":
800
+ await cmdLogin(flags);
801
+ break;
802
+ case "init":
803
+ await cmdInit(flags);
804
+ break;
805
+ case "start":
806
+ await cmdStart(flags);
807
+ break;
808
+ case "goto":
809
+ await cmdGoto(args, flags);
810
+ break;
811
+ case "ask":
812
+ await cmdAsk(args, flags);
813
+ break;
814
+ case "ls":
815
+ case "list":
816
+ await cmdLs(flags);
817
+ break;
818
+ case "status":
819
+ await cmdStatus(flags);
820
+ break;
821
+ case "rm":
822
+ case "stop":
823
+ await cmdRm(args, flags);
824
+ break;
825
+ case "personas":
826
+ await cmdPersonas(args, flags);
827
+ break;
828
+ case "open":
829
+ await cmdOpen(flags);
830
+ break;
831
+ case "config":
832
+ await cmdConfig(args, flags);
833
+ break;
834
+ case "usage":
835
+ await cmdUsage(flags);
836
+ break;
837
+ case "stealth-test":
838
+ await cmdStealthTest(flags);
839
+ break;
840
+ case "whoami":
841
+ console.log(JSON.stringify({ ...resolved(), apiKey: load().apiKey ? "saved" : "none" }, null, 2));
842
+ break;
843
+ case "help":
844
+ case "--help":
845
+ case "-h":
846
+ console.log(HELP);
847
+ break;
848
+ default:
849
+ console.error(`Unknown command: ${command}
850
+ `);
851
+ console.log(HELP);
852
+ process.exit(1);
853
+ }
854
+ } catch (err) {
855
+ const error = err;
856
+ console.error(`
857
+ \u2717 ${error.message}`);
858
+ if (error.status === 401) console.error(" The API key was rejected. Run `oya login`.");
859
+ if (error.status === 429) console.error(" A quota or a persona concurrency cap. `oya personas` shows what is running.");
860
+ process.exit(1);
861
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@oya-ai/cli",
3
+ "version": "0.1.0",
4
+ "description": "Command line for the Oya browser control plane.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "oya": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsup",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "devDependencies": {
24
+ "@oya-ai/browser": "0.1.0",
25
+ "@types/node": "^20.19.43",
26
+ "tsup": "^8.0.0",
27
+ "typescript": "^5.4.0"
28
+ }
29
+ }