@leg3ndy/otto-bridge 0.3.0 → 0.4.1
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 +1 -1
- package/dist/executors/native_macos.js +244 -36
- package/dist/types.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, readdir, stat } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import process from "node:process";
|
|
3
6
|
import { JobCancelledError } from "./shared.js";
|
|
4
7
|
const KNOWN_APPS = [
|
|
@@ -16,14 +19,15 @@ const KNOWN_APPS = [
|
|
|
16
19
|
{ canonical: "System Settings", patterns: [/\bsystem settings\b/i, /\bajustes do sistema\b/i, /\bconfigura[cç][õo]es do sistema\b/i] },
|
|
17
20
|
];
|
|
18
21
|
const KNOWN_SITES = [
|
|
19
|
-
{ url: "https://www.youtube.com", patterns: [/\byoutube\b/i, /\byou tube\b/i] },
|
|
20
|
-
{ url: "https://
|
|
21
|
-
{ url: "https://
|
|
22
|
-
{ url: "https://
|
|
23
|
-
{ url: "https://
|
|
24
|
-
{ url: "https://
|
|
25
|
-
{ url: "https://
|
|
26
|
-
{ url: "https://
|
|
22
|
+
{ label: "YouTube", url: "https://www.youtube.com", patterns: [/\byoutube\b/i, /\byou tube\b/i] },
|
|
23
|
+
{ label: "YouTube Music", url: "https://music.youtube.com", patterns: [/\byoutube music\b/i] },
|
|
24
|
+
{ label: "Instagram", url: "https://www.instagram.com", patterns: [/\binstagram\b/i, /\binstagram\.com\b/i] },
|
|
25
|
+
{ label: "Gmail", url: "https://mail.google.com", patterns: [/\bgmail\b/i] },
|
|
26
|
+
{ label: "Google", url: "https://www.google.com", patterns: [/\bgoogle\b/i] },
|
|
27
|
+
{ label: "GitHub", url: "https://github.com", patterns: [/\bgithub\b/i] },
|
|
28
|
+
{ label: "ChatGPT", url: "https://chat.openai.com", patterns: [/\bchatgpt\b/i] },
|
|
29
|
+
{ label: "WhatsApp Web", url: "https://web.whatsapp.com", patterns: [/\bwhatsapp\b/i] },
|
|
30
|
+
{ label: "X", url: "https://x.com", patterns: [/\bx\.com\b/i, /\btwitter\b/i, /\bxis\b/i] },
|
|
27
31
|
];
|
|
28
32
|
function asRecord(value) {
|
|
29
33
|
return value && typeof value === "object" ? value : {};
|
|
@@ -68,18 +72,92 @@ function normalizeUrl(raw) {
|
|
|
68
72
|
}
|
|
69
73
|
return `https://${trimmed.replace(/^\/+/, "")}`;
|
|
70
74
|
}
|
|
71
|
-
function
|
|
72
|
-
const
|
|
73
|
-
for (const
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
for (let index = 0; index < line.length; index += 220) {
|
|
79
|
-
parts.push(line.slice(index, index + 220));
|
|
75
|
+
function humanizeUrl(url) {
|
|
76
|
+
const normalized = normalizeUrl(url);
|
|
77
|
+
for (const site of KNOWN_SITES) {
|
|
78
|
+
if (normalizeUrl(site.url) === normalized) {
|
|
79
|
+
return site.label || site.url;
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
try {
|
|
83
|
+
const parsed = new URL(normalized);
|
|
84
|
+
return parsed.hostname.replace(/^www\./i, "");
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return normalized;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function expandUserPath(value) {
|
|
91
|
+
const trimmed = value.trim();
|
|
92
|
+
if (!trimmed) {
|
|
93
|
+
return os.homedir();
|
|
94
|
+
}
|
|
95
|
+
if (trimmed === "~") {
|
|
96
|
+
return os.homedir();
|
|
97
|
+
}
|
|
98
|
+
if (trimmed.startsWith("~/")) {
|
|
99
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
100
|
+
}
|
|
101
|
+
if (path.isAbsolute(trimmed)) {
|
|
102
|
+
return trimmed;
|
|
103
|
+
}
|
|
104
|
+
return path.resolve(process.cwd(), trimmed);
|
|
105
|
+
}
|
|
106
|
+
function clipText(value, maxLength) {
|
|
107
|
+
if (value.length <= maxLength) {
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
return `${value.slice(0, maxLength)}...`;
|
|
111
|
+
}
|
|
112
|
+
function isSafeShellCommand(command) {
|
|
113
|
+
const trimmed = command.trim();
|
|
114
|
+
if (!trimmed) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const forbiddenPatterns = [
|
|
118
|
+
/(^|[;&|])\s*sudo\b/i,
|
|
119
|
+
/\brm\b/i,
|
|
120
|
+
/\bmv\b/i,
|
|
121
|
+
/\bcp\b/i,
|
|
122
|
+
/\bchmod\b/i,
|
|
123
|
+
/\bchown\b/i,
|
|
124
|
+
/\bshutdown\b/i,
|
|
125
|
+
/\breboot\b/i,
|
|
126
|
+
/\bmkfs\b/i,
|
|
127
|
+
/\bdd\b/i,
|
|
128
|
+
/\bkill(?:all)?\b/i,
|
|
129
|
+
/>/,
|
|
130
|
+
/>>/,
|
|
131
|
+
];
|
|
132
|
+
if (forbiddenPatterns.some((pattern) => pattern.test(trimmed))) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const normalized = trimmed.replace(/\s+/g, " ");
|
|
136
|
+
const allowedPrefixes = [
|
|
137
|
+
"pwd",
|
|
138
|
+
"ls",
|
|
139
|
+
"cat ",
|
|
140
|
+
"cat",
|
|
141
|
+
"sed ",
|
|
142
|
+
"rg ",
|
|
143
|
+
"find ",
|
|
144
|
+
"git status",
|
|
145
|
+
"git log",
|
|
146
|
+
"git diff",
|
|
147
|
+
"head ",
|
|
148
|
+
"tail ",
|
|
149
|
+
"wc ",
|
|
150
|
+
"stat ",
|
|
151
|
+
"file ",
|
|
152
|
+
"mdls ",
|
|
153
|
+
"whoami",
|
|
154
|
+
"date",
|
|
155
|
+
"uname",
|
|
156
|
+
"python3 --version",
|
|
157
|
+
"node -v",
|
|
158
|
+
"npm -v",
|
|
159
|
+
];
|
|
160
|
+
return allowedPrefixes.some((prefix) => normalized === prefix || normalized.startsWith(`${prefix} `));
|
|
83
161
|
}
|
|
84
162
|
function extractConfirmationOptions(job, actions) {
|
|
85
163
|
const payload = asRecord(job.payload);
|
|
@@ -169,6 +247,33 @@ function parseStructuredActions(job) {
|
|
|
169
247
|
if (shortcut) {
|
|
170
248
|
actions.push({ type: "press_shortcut", shortcut });
|
|
171
249
|
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (type === "take_screenshot" || type === "screenshot" || type === "screen_capture") {
|
|
253
|
+
const savePath = asString(action.path) || asString(action.save_path);
|
|
254
|
+
actions.push({ type: "take_screenshot", path: savePath || undefined });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (type === "read_file" || type === "read_local_file") {
|
|
258
|
+
const filePath = asString(action.path);
|
|
259
|
+
if (filePath) {
|
|
260
|
+
const maxChars = typeof action.max_chars === "number" ? Math.max(200, Math.min(12000, action.max_chars)) : undefined;
|
|
261
|
+
actions.push({ type: "read_file", path: filePath, max_chars: maxChars });
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (type === "list_files" || type === "ls") {
|
|
266
|
+
const filePath = asString(action.path) || "~";
|
|
267
|
+
const limit = typeof action.limit === "number" ? Math.max(1, Math.min(200, action.limit)) : undefined;
|
|
268
|
+
actions.push({ type: "list_files", path: filePath, limit });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (type === "run_shell" || type === "shell" || type === "terminal") {
|
|
272
|
+
const command = asString(action.command) || asString(action.cmd);
|
|
273
|
+
const cwd = asString(action.cwd);
|
|
274
|
+
if (command) {
|
|
275
|
+
actions.push({ type: "run_shell", command, cwd: cwd || undefined });
|
|
276
|
+
}
|
|
172
277
|
}
|
|
173
278
|
}
|
|
174
279
|
return actions;
|
|
@@ -222,6 +327,7 @@ export class NativeMacOSJobExecutor {
|
|
|
222
327
|
}
|
|
223
328
|
}
|
|
224
329
|
try {
|
|
330
|
+
const completionNotes = [];
|
|
225
331
|
for (let index = 0; index < actions.length; index += 1) {
|
|
226
332
|
this.assertNotCancelled(job.job_id);
|
|
227
333
|
const action = actions[index];
|
|
@@ -246,12 +352,38 @@ export class NativeMacOSJobExecutor {
|
|
|
246
352
|
await this.typeText(action.text);
|
|
247
353
|
continue;
|
|
248
354
|
}
|
|
355
|
+
if (action.type === "take_screenshot") {
|
|
356
|
+
await reporter.progress(progressPercent, "Capturando screenshot do Mac");
|
|
357
|
+
const screenshotPath = await this.takeScreenshot(action.path);
|
|
358
|
+
completionNotes.push(`Screenshot salvo em ${screenshotPath}`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (action.type === "read_file") {
|
|
362
|
+
await reporter.progress(progressPercent, `Lendo ${action.path}`);
|
|
363
|
+
const fileContent = await this.readLocalFile(action.path, action.max_chars);
|
|
364
|
+
completionNotes.push(`Conteudo de ${action.path}:\n${fileContent}`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (action.type === "list_files") {
|
|
368
|
+
await reporter.progress(progressPercent, `Listando arquivos em ${action.path}`);
|
|
369
|
+
const listing = await this.listLocalFiles(action.path, action.limit);
|
|
370
|
+
completionNotes.push(`Arquivos em ${action.path}:\n${listing}`);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (action.type === "run_shell") {
|
|
374
|
+
await reporter.progress(progressPercent, `Rodando comando local: ${action.command}`);
|
|
375
|
+
const shellOutput = await this.runShellCommand(action.command, action.cwd);
|
|
376
|
+
completionNotes.push(`Saida de \`${action.command}\`:\n${shellOutput}`);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
249
379
|
await reporter.progress(progressPercent, `Abrindo ${action.url}${action.app ? ` em ${action.app}` : ""}`);
|
|
250
380
|
await this.openUrl(action.url, action.app);
|
|
251
381
|
}
|
|
252
|
-
const summary =
|
|
253
|
-
?
|
|
254
|
-
:
|
|
382
|
+
const summary = completionNotes.length > 0
|
|
383
|
+
? completionNotes.join("\n\n")
|
|
384
|
+
: (actions.length === 1
|
|
385
|
+
? this.describeAction(actions[0])
|
|
386
|
+
: `${actions.length} ações executadas no macOS`);
|
|
255
387
|
await reporter.completed({
|
|
256
388
|
executor: "native-macos",
|
|
257
389
|
summary,
|
|
@@ -320,23 +452,71 @@ export class NativeMacOSJobExecutor {
|
|
|
320
452
|
]);
|
|
321
453
|
}
|
|
322
454
|
async typeText(text) {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
455
|
+
const previousClipboard = await this.readClipboardText();
|
|
456
|
+
try {
|
|
457
|
+
await this.writeClipboardText(text);
|
|
458
|
+
await this.pressShortcut("cmd+v");
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
if (previousClipboard !== null) {
|
|
462
|
+
await this.writeClipboardText(previousClipboard).catch(() => undefined);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async takeScreenshot(targetPath) {
|
|
467
|
+
const artifactsDir = path.join(os.homedir(), ".otto-bridge", "artifacts");
|
|
468
|
+
await mkdir(artifactsDir, { recursive: true });
|
|
469
|
+
const screenshotPath = targetPath
|
|
470
|
+
? expandUserPath(targetPath)
|
|
471
|
+
: path.join(artifactsDir, `screenshot-${Date.now()}.png`);
|
|
472
|
+
await this.runCommand("screencapture", ["-x", screenshotPath]);
|
|
473
|
+
return screenshotPath;
|
|
474
|
+
}
|
|
475
|
+
async readLocalFile(filePath, maxChars = 4000) {
|
|
476
|
+
const resolved = expandUserPath(filePath);
|
|
477
|
+
const content = await readFile(resolved, "utf8");
|
|
478
|
+
return clipText(content.trim() || "(arquivo vazio)", maxChars);
|
|
479
|
+
}
|
|
480
|
+
async listLocalFiles(directoryPath, limit = 40) {
|
|
481
|
+
const resolved = expandUserPath(directoryPath);
|
|
482
|
+
const entries = await readdir(resolved, { withFileTypes: true });
|
|
483
|
+
const items = await Promise.all(entries.slice(0, limit).map(async (entry) => {
|
|
484
|
+
const entryPath = path.join(resolved, entry.name);
|
|
485
|
+
let suffix = "";
|
|
486
|
+
try {
|
|
487
|
+
const entryStat = await stat(entryPath);
|
|
488
|
+
suffix = entry.isDirectory() ? "/" : ` (${entryStat.size} bytes)`;
|
|
331
489
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
"-e",
|
|
335
|
-
'tell application "System Events" to key code 36',
|
|
336
|
-
]);
|
|
490
|
+
catch {
|
|
491
|
+
suffix = entry.isDirectory() ? "/" : "";
|
|
337
492
|
}
|
|
493
|
+
return `${entry.name}${suffix}`;
|
|
494
|
+
}));
|
|
495
|
+
return items.length > 0 ? items.join("\n") : "(pasta vazia)";
|
|
496
|
+
}
|
|
497
|
+
async runShellCommand(command, cwd) {
|
|
498
|
+
if (!isSafeShellCommand(command)) {
|
|
499
|
+
throw new Error("Otto Bridge permite apenas shell de consulta no momento. Use comandos de leitura como pwd, ls, cat, rg, find ou git status.");
|
|
500
|
+
}
|
|
501
|
+
const resolvedCwd = cwd ? expandUserPath(cwd) : process.cwd();
|
|
502
|
+
const { stdout, stderr } = await this.runCommandCapture("/bin/zsh", ["-lc", command], {
|
|
503
|
+
cwd: resolvedCwd,
|
|
504
|
+
});
|
|
505
|
+
const combined = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
506
|
+
return clipText(combined || "(sem saida)", 4000);
|
|
507
|
+
}
|
|
508
|
+
async readClipboardText() {
|
|
509
|
+
try {
|
|
510
|
+
const { stdout } = await this.runCommandCapture("pbpaste", []);
|
|
511
|
+
return stdout;
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
return null;
|
|
338
515
|
}
|
|
339
516
|
}
|
|
517
|
+
async writeClipboardText(text) {
|
|
518
|
+
await this.runCommandCapture("pbcopy", [], { stdin: text });
|
|
519
|
+
}
|
|
340
520
|
describeAction(action) {
|
|
341
521
|
if (action.type === "open_app") {
|
|
342
522
|
return `${action.app} foi aberto no macOS`;
|
|
@@ -350,17 +530,41 @@ export class NativeMacOSJobExecutor {
|
|
|
350
530
|
if (action.type === "type_text") {
|
|
351
531
|
return "Texto digitado no aplicativo ativo";
|
|
352
532
|
}
|
|
353
|
-
|
|
533
|
+
if (action.type === "take_screenshot") {
|
|
534
|
+
return "Screenshot capturado no macOS";
|
|
535
|
+
}
|
|
536
|
+
if (action.type === "read_file") {
|
|
537
|
+
return `${action.path} foi lido no macOS`;
|
|
538
|
+
}
|
|
539
|
+
if (action.type === "list_files") {
|
|
540
|
+
return `Arquivos listados em ${action.path}`;
|
|
541
|
+
}
|
|
542
|
+
if (action.type === "run_shell") {
|
|
543
|
+
return `Comando ${action.command} executado no macOS`;
|
|
544
|
+
}
|
|
545
|
+
const target = humanizeUrl(action.url);
|
|
546
|
+
return `${target} foi aberto${action.app ? ` em ${action.app}` : ""}`;
|
|
354
547
|
}
|
|
355
548
|
async runCommand(command, args) {
|
|
549
|
+
await this.runCommandCapture(command, args);
|
|
550
|
+
}
|
|
551
|
+
async runCommandCapture(command, args, options) {
|
|
356
552
|
const child = spawn(command, args, {
|
|
357
|
-
|
|
553
|
+
cwd: options?.cwd,
|
|
554
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
358
555
|
});
|
|
359
556
|
this.activeChild = child;
|
|
360
557
|
try {
|
|
361
558
|
const { stdout, stderr } = await new Promise((resolve, reject) => {
|
|
362
559
|
let stdout = "";
|
|
363
560
|
let stderr = "";
|
|
561
|
+
if (options?.stdin !== undefined) {
|
|
562
|
+
child.stdin.write(options.stdin);
|
|
563
|
+
child.stdin.end();
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
child.stdin.end();
|
|
567
|
+
}
|
|
364
568
|
child.stdout.on("data", (chunk) => {
|
|
365
569
|
stdout += String(chunk);
|
|
366
570
|
});
|
|
@@ -386,9 +590,13 @@ export class NativeMacOSJobExecutor {
|
|
|
386
590
|
if (stdoutText) {
|
|
387
591
|
console.log(`[otto-bridge] ${command} stdout=${stdoutText}`);
|
|
388
592
|
}
|
|
593
|
+
return { stdout, stderr };
|
|
389
594
|
}
|
|
390
595
|
catch (error) {
|
|
391
596
|
const detail = error instanceof Error ? error.message : String(error);
|
|
597
|
+
if (detail.includes("System Events") || detail.includes("(1002)")) {
|
|
598
|
+
throw new Error("macOS bloqueou o controle de teclado do Otto Bridge. Abra Ajustes do Sistema > Privacidade e Seguranca > Acessibilidade e autorize o terminal ou app que esta rodando o otto-bridge.");
|
|
599
|
+
}
|
|
392
600
|
throw new Error(detail);
|
|
393
601
|
}
|
|
394
602
|
finally {
|
package/dist/types.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const BRIDGE_CONFIG_VERSION = 1;
|
|
2
|
-
export const BRIDGE_VERSION = "0.
|
|
2
|
+
export const BRIDGE_VERSION = "0.4.1";
|
|
3
3
|
export const BRIDGE_PACKAGE_NAME = "@leg3ndy/otto-bridge";
|
|
4
4
|
export const DEFAULT_API_BASE_URL = "http://localhost:8000";
|
|
5
5
|
export const DEFAULT_POLL_INTERVAL_MS = 3000;
|