@pulso/companion 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.
- package/dist/index.js +254 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import WebSocket from "ws";
|
|
5
|
+
import { exec, execSync } from "child_process";
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { resolve } from "path";
|
|
9
|
+
var API_URL = process.env.PULSO_API_URL ?? process.argv.find((_, i, a) => a[i - 1] === "--api") ?? "https://pulso-api.vulk.workers.dev";
|
|
10
|
+
var TOKEN = process.env.PULSO_TOKEN ?? process.argv.find((_, i, a) => a[i - 1] === "--token") ?? "";
|
|
11
|
+
if (!TOKEN) {
|
|
12
|
+
console.error("\u274C Missing token. Set PULSO_TOKEN or use --token <jwt>");
|
|
13
|
+
console.error(" Get your token: log in to Pulso \u2192 Settings \u2192 Companion Token");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
var ACCESS_LEVEL = process.env.PULSO_ACCESS ?? process.argv.find((_, i, a) => a[i - 1] === "--access") ?? "sandboxed";
|
|
17
|
+
var WS_URL = API_URL.replace("https://", "wss://").replace("http://", "ws://") + "/ws/browser?token=" + TOKEN;
|
|
18
|
+
var HOME = homedir();
|
|
19
|
+
var RECONNECT_DELAY = 5e3;
|
|
20
|
+
var SAFE_DIRS = ["Documents", "Desktop", "Downloads", "Projects", "Projetos"];
|
|
21
|
+
function safePath(relative) {
|
|
22
|
+
const full = resolve(HOME, relative);
|
|
23
|
+
if (!full.startsWith(HOME)) return null;
|
|
24
|
+
if (ACCESS_LEVEL === "full") return full;
|
|
25
|
+
const relFromHome = full.slice(HOME.length + 1);
|
|
26
|
+
const topDir = relFromHome.split("/")[0];
|
|
27
|
+
if (!topDir || !SAFE_DIRS.some((d) => topDir.toLowerCase() === d.toLowerCase())) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return full;
|
|
31
|
+
}
|
|
32
|
+
function runAppleScript(script) {
|
|
33
|
+
return new Promise((resolve2, reject) => {
|
|
34
|
+
exec(`osascript -e '${script.replace(/'/g, "'\\''")}'`, { timeout: 15e3 }, (err, stdout, stderr) => {
|
|
35
|
+
if (err) reject(new Error(stderr || err.message));
|
|
36
|
+
else resolve2(stdout.trim());
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function runShell(cmd, timeout = 1e4) {
|
|
41
|
+
return new Promise((resolve2, reject) => {
|
|
42
|
+
exec(cmd, { timeout }, (err, stdout, stderr) => {
|
|
43
|
+
if (err) reject(new Error(stderr || err.message));
|
|
44
|
+
else resolve2(stdout.trim());
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function handleCommand(command, params) {
|
|
49
|
+
try {
|
|
50
|
+
switch (command) {
|
|
51
|
+
case "sys_open_app": {
|
|
52
|
+
const app = params.app;
|
|
53
|
+
if (!app) return { success: false, error: "Missing app name" };
|
|
54
|
+
await runShell(`open -a "${app.replace(/"/g, "")}"`);
|
|
55
|
+
return { success: true, data: { opened: app } };
|
|
56
|
+
}
|
|
57
|
+
case "sys_open_url": {
|
|
58
|
+
const url = params.url;
|
|
59
|
+
if (!url) return { success: false, error: "Missing URL" };
|
|
60
|
+
await runShell(`open "${url.replace(/"/g, "")}"`);
|
|
61
|
+
return { success: true, data: { opened: url } };
|
|
62
|
+
}
|
|
63
|
+
case "sys_speak": {
|
|
64
|
+
const text = params.text;
|
|
65
|
+
const voice = params.voice;
|
|
66
|
+
if (!text) return { success: false, error: "Missing text" };
|
|
67
|
+
const voiceFlag = voice ? `-v "${voice.replace(/"/g, "")}"` : "";
|
|
68
|
+
exec(`say ${voiceFlag} "${text.replace(/"/g, '\\"').slice(0, 500)}"`);
|
|
69
|
+
return { success: true, data: { spoken: text.slice(0, 100) } };
|
|
70
|
+
}
|
|
71
|
+
case "sys_notification": {
|
|
72
|
+
const title = params.title;
|
|
73
|
+
const message = params.message;
|
|
74
|
+
if (!title || !message) return { success: false, error: "Missing title or message" };
|
|
75
|
+
await runAppleScript(
|
|
76
|
+
`display notification "${message.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`
|
|
77
|
+
);
|
|
78
|
+
return { success: true, data: { notified: true } };
|
|
79
|
+
}
|
|
80
|
+
case "sys_clipboard_read": {
|
|
81
|
+
const content = await runShell("pbpaste");
|
|
82
|
+
return { success: true, data: { content: content.slice(0, 5e3) } };
|
|
83
|
+
}
|
|
84
|
+
case "sys_clipboard_write": {
|
|
85
|
+
const text = params.text;
|
|
86
|
+
if (!text) return { success: false, error: "Missing text" };
|
|
87
|
+
execSync(`echo ${JSON.stringify(text)} | pbcopy`);
|
|
88
|
+
return { success: true, data: { copied: text.slice(0, 100) } };
|
|
89
|
+
}
|
|
90
|
+
case "sys_spotify": {
|
|
91
|
+
const action = params.action;
|
|
92
|
+
switch (action) {
|
|
93
|
+
case "play":
|
|
94
|
+
await runAppleScript('tell application "Spotify" to play');
|
|
95
|
+
return { success: true, data: { action: "play" } };
|
|
96
|
+
case "pause":
|
|
97
|
+
await runAppleScript('tell application "Spotify" to pause');
|
|
98
|
+
return { success: true, data: { action: "pause" } };
|
|
99
|
+
case "next":
|
|
100
|
+
await runAppleScript('tell application "Spotify" to next track');
|
|
101
|
+
return { success: true, data: { action: "next" } };
|
|
102
|
+
case "previous":
|
|
103
|
+
await runAppleScript('tell application "Spotify" to previous track');
|
|
104
|
+
return { success: true, data: { action: "previous" } };
|
|
105
|
+
case "now_playing": {
|
|
106
|
+
const name = await runAppleScript('tell application "Spotify" to name of current track');
|
|
107
|
+
const artist = await runAppleScript('tell application "Spotify" to artist of current track');
|
|
108
|
+
const album = await runAppleScript('tell application "Spotify" to album of current track');
|
|
109
|
+
const state = await runAppleScript('tell application "Spotify" to player state as string');
|
|
110
|
+
return { success: true, data: { track: name, artist, album, state } };
|
|
111
|
+
}
|
|
112
|
+
case "search_play": {
|
|
113
|
+
const query = params.query;
|
|
114
|
+
if (!query) return { success: false, error: "Missing search query" };
|
|
115
|
+
await runShell(`open "spotify:search:${encodeURIComponent(query)}"`);
|
|
116
|
+
return { success: true, data: { searched: query, note: "Opened Spotify search. Play the first result manually or use 'play' after." } };
|
|
117
|
+
}
|
|
118
|
+
default:
|
|
119
|
+
return { success: false, error: `Unknown Spotify action: ${action}` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
case "sys_file_read": {
|
|
123
|
+
const path = params.path;
|
|
124
|
+
if (!path) return { success: false, error: "Missing file path" };
|
|
125
|
+
const fullPath = safePath(path);
|
|
126
|
+
if (!fullPath) return { success: false, error: `Access denied. Only files in ${SAFE_DIRS.join(", ")} are allowed.` };
|
|
127
|
+
if (!existsSync(fullPath)) return { success: false, error: `File not found: ${path}` };
|
|
128
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
129
|
+
return {
|
|
130
|
+
success: true,
|
|
131
|
+
data: {
|
|
132
|
+
path,
|
|
133
|
+
content: content.slice(0, 1e4),
|
|
134
|
+
size: content.length,
|
|
135
|
+
truncated: content.length > 1e4
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
case "sys_file_write": {
|
|
140
|
+
const path = params.path;
|
|
141
|
+
const content = params.content;
|
|
142
|
+
if (!path || !content) return { success: false, error: "Missing path or content" };
|
|
143
|
+
const fullPath = safePath(path);
|
|
144
|
+
if (!fullPath) return { success: false, error: `Access denied. Only files in ${SAFE_DIRS.join(", ")} are allowed.` };
|
|
145
|
+
writeFileSync(fullPath, content, "utf-8");
|
|
146
|
+
return { success: true, data: { path, written: content.length } };
|
|
147
|
+
}
|
|
148
|
+
case "sys_screenshot": {
|
|
149
|
+
const tmpPath = `/tmp/pulso-screenshot-${Date.now()}.png`;
|
|
150
|
+
await runShell(`screencapture -x ${tmpPath}`, 15e3);
|
|
151
|
+
if (!existsSync(tmpPath)) return { success: false, error: "Screenshot failed" };
|
|
152
|
+
const buf = readFileSync(tmpPath);
|
|
153
|
+
const base64 = buf.toString("base64");
|
|
154
|
+
exec(`rm -f ${tmpPath}`);
|
|
155
|
+
return {
|
|
156
|
+
success: true,
|
|
157
|
+
data: {
|
|
158
|
+
image: `data:image/png;base64,${base64}`,
|
|
159
|
+
note: "Full screen screenshot captured"
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
case "sys_run_shortcut": {
|
|
164
|
+
const name = params.name;
|
|
165
|
+
const input = params.input;
|
|
166
|
+
if (!name) return { success: false, error: "Missing shortcut name" };
|
|
167
|
+
const inputFlag = input ? `--input-type text --input "${input.replace(/"/g, '\\"')}"` : "";
|
|
168
|
+
const result = await runShell(`shortcuts run "${name.replace(/"/g, '\\"')}" ${inputFlag}`, 3e4);
|
|
169
|
+
return { success: true, data: { shortcut: name, output: result || "Shortcut executed" } };
|
|
170
|
+
}
|
|
171
|
+
default:
|
|
172
|
+
return { success: false, error: `Unknown command: ${command}` };
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return { success: false, error: err.message };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
var ws = null;
|
|
179
|
+
var reconnectTimer = null;
|
|
180
|
+
function connect() {
|
|
181
|
+
console.log("\u{1F50C} Connecting to Pulso...");
|
|
182
|
+
console.log(` ${WS_URL.replace(/token=.*/, "token=***")}`);
|
|
183
|
+
ws = new WebSocket(WS_URL);
|
|
184
|
+
ws.on("open", () => {
|
|
185
|
+
console.log("\u2705 Connected to Pulso!");
|
|
186
|
+
console.log(`\u{1F5A5}\uFE0F Companion is active \u2014 ${ACCESS_LEVEL === "full" ? "full device access" : "sandboxed mode"}`);
|
|
187
|
+
console.log("");
|
|
188
|
+
console.log(" Available capabilities:");
|
|
189
|
+
console.log(" \u2022 Open apps & URLs");
|
|
190
|
+
console.log(" \u2022 Control Spotify & media");
|
|
191
|
+
console.log(` \u2022 Read/write files ${ACCESS_LEVEL === "full" ? "(full device)" : "(Documents, Desktop, Downloads)"}`);
|
|
192
|
+
console.log(" \u2022 Clipboard access");
|
|
193
|
+
console.log(" \u2022 Screenshots");
|
|
194
|
+
console.log(" \u2022 Text-to-speech");
|
|
195
|
+
console.log(" \u2022 Terminal commands");
|
|
196
|
+
console.log(" \u2022 System notifications");
|
|
197
|
+
console.log(" \u2022 macOS Shortcuts");
|
|
198
|
+
console.log("");
|
|
199
|
+
console.log(` Access: ${ACCESS_LEVEL === "full" ? "\u{1F513} Full (unrestricted)" : "\u{1F512} Sandboxed (safe dirs only)"}`);
|
|
200
|
+
console.log(" Waiting for commands from Pulso agent...");
|
|
201
|
+
ws.send(JSON.stringify({ type: "extension_ready" }));
|
|
202
|
+
});
|
|
203
|
+
ws.on("message", async (raw) => {
|
|
204
|
+
try {
|
|
205
|
+
const msg = JSON.parse(raw.toString());
|
|
206
|
+
if (msg.type === "ack") {
|
|
207
|
+
console.log(` \u2713 Server: ${msg.message}`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (msg.id && msg.command) {
|
|
211
|
+
console.log(`
|
|
212
|
+
\u26A1 Command: ${msg.command}`, msg.params ? JSON.stringify(msg.params).slice(0, 200) : "");
|
|
213
|
+
const result = await handleCommand(msg.command, msg.params ?? {});
|
|
214
|
+
console.log(` \u2192 ${result.success ? "\u2705" : "\u274C"}`, result.success ? JSON.stringify(result.data).slice(0, 200) : result.error);
|
|
215
|
+
ws.send(JSON.stringify({ id: msg.id, result }));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
console.log(" ? Unknown message:", raw.toString().slice(0, 200));
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error(" \u274C Error handling message:", err.message);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
ws.on("close", (code, reason) => {
|
|
224
|
+
console.log(`
|
|
225
|
+
\u{1F50C} Disconnected (${code}: ${reason.toString() || "unknown"})`);
|
|
226
|
+
scheduleReconnect();
|
|
227
|
+
});
|
|
228
|
+
ws.on("error", (err) => {
|
|
229
|
+
console.error(`\u274C WebSocket error: ${err.message}`);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function scheduleReconnect() {
|
|
233
|
+
if (reconnectTimer) return;
|
|
234
|
+
console.log(` Reconnecting in ${RECONNECT_DELAY / 1e3}s...`);
|
|
235
|
+
reconnectTimer = setTimeout(() => {
|
|
236
|
+
reconnectTimer = null;
|
|
237
|
+
connect();
|
|
238
|
+
}, RECONNECT_DELAY);
|
|
239
|
+
}
|
|
240
|
+
console.log("");
|
|
241
|
+
console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
|
|
242
|
+
console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.1.0 \u2551");
|
|
243
|
+
console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
|
|
244
|
+
console.log("");
|
|
245
|
+
connect();
|
|
246
|
+
process.on("SIGINT", () => {
|
|
247
|
+
console.log("\n\u{1F44B} Shutting down Pulso Companion...");
|
|
248
|
+
ws?.close(1e3, "User shutdown");
|
|
249
|
+
process.exit(0);
|
|
250
|
+
});
|
|
251
|
+
process.on("SIGTERM", () => {
|
|
252
|
+
ws?.close(1e3, "Process terminated");
|
|
253
|
+
process.exit(0);
|
|
254
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pulso/companion",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Pulso Companion — gives your AI agent real control over your computer",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pulso-companion": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "tsx src/index.ts",
|
|
14
|
+
"build": "tsup",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pulso",
|
|
19
|
+
"ai",
|
|
20
|
+
"agent",
|
|
21
|
+
"companion",
|
|
22
|
+
"mac",
|
|
23
|
+
"automation"
|
|
24
|
+
],
|
|
25
|
+
"author": "Pulso <hello@pulso.dev>",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/pulso-dev/pulso"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18.0.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"ws": "^8.18.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/ws": "^8.5.0",
|
|
39
|
+
"tsx": "^4.0.0",
|
|
40
|
+
"tsup": "^8.0.0",
|
|
41
|
+
"typescript": "^5.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|