junis 0.2.6 → 0.3.2
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 +17 -4
- package/dist/cli/index.js +1305 -325
- package/dist/server/mcp.js +1102 -209
- package/dist/server/stdio.js +766 -207
- package/package.json +4 -2
package/dist/cli/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "junis",
|
|
34
|
-
version: "0.2
|
|
34
|
+
version: "0.3.2",
|
|
35
35
|
description: "One-line device control for AI agents",
|
|
36
36
|
bin: {
|
|
37
37
|
junis: "dist/cli/index.js"
|
|
@@ -45,11 +45,13 @@ var require_package = __commonJS({
|
|
|
45
45
|
dependencies: {
|
|
46
46
|
"@inquirer/prompts": "^8.2.1",
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
48
|
+
browserclaw: "^0.2.7",
|
|
48
49
|
commander: "^12.0.0",
|
|
50
|
+
execa: "^8.0.0",
|
|
49
51
|
glob: "^11.0.0",
|
|
50
52
|
"node-notifier": "^10.0.1",
|
|
51
53
|
open: "^10.1.0",
|
|
52
|
-
playwright: "
|
|
54
|
+
"playwright-core": ">=1.50.0",
|
|
53
55
|
ws: "^8.18.0",
|
|
54
56
|
zod: "^4.3.6"
|
|
55
57
|
},
|
|
@@ -94,11 +96,12 @@ function saveConfig(config) {
|
|
|
94
96
|
try {
|
|
95
97
|
import_fs.default.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
96
98
|
import_fs.default.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
99
|
+
import_fs.default.chmodSync(CONFIG_FILE, 384);
|
|
97
100
|
} catch (err) {
|
|
98
101
|
console.error(`
|
|
99
|
-
\u274C
|
|
100
|
-
console.error(`
|
|
101
|
-
console.error(`
|
|
102
|
+
\u274C Failed to save config file: ${err.message}`);
|
|
103
|
+
console.error(` Please save it manually to ${CONFIG_FILE}.`);
|
|
104
|
+
console.error(` Content: ${JSON.stringify(config, null, 2)}`);
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
function clearConfig() {
|
|
@@ -123,23 +126,32 @@ var JUNIS_WEB = (() => {
|
|
|
123
126
|
}
|
|
124
127
|
return null;
|
|
125
128
|
})();
|
|
126
|
-
async function authenticate(deviceName,
|
|
129
|
+
async function authenticate(deviceName, platform3, onBrowserOpen, onWaiting, existingDeviceKey, existingToken, oldDeviceKey) {
|
|
130
|
+
const headers = { "Content-Type": "application/json" };
|
|
131
|
+
if (existingToken) {
|
|
132
|
+
headers["Authorization"] = `Bearer ${existingToken}`;
|
|
133
|
+
}
|
|
127
134
|
let startRes;
|
|
128
135
|
try {
|
|
129
136
|
startRes = await fetch(`${JUNIS_API}/api/auth/device/start`, {
|
|
130
137
|
method: "POST",
|
|
131
|
-
headers
|
|
132
|
-
body: JSON.stringify({
|
|
138
|
+
headers,
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
device_name: deviceName,
|
|
141
|
+
platform: platform3,
|
|
142
|
+
...existingDeviceKey && { device_key: existingDeviceKey },
|
|
143
|
+
...oldDeviceKey ? { old_device_key: oldDeviceKey } : {}
|
|
144
|
+
})
|
|
133
145
|
});
|
|
134
146
|
} catch (err) {
|
|
135
147
|
throw new Error(
|
|
136
|
-
|
|
148
|
+
`Cannot connect to server. Please check your internet connection or try again later.
|
|
137
149
|
(${err.message})`
|
|
138
150
|
);
|
|
139
151
|
}
|
|
140
152
|
if (!startRes.ok) {
|
|
141
153
|
const body = await startRes.text().catch(() => "");
|
|
142
|
-
throw new Error(`Auth
|
|
154
|
+
throw new Error(`Auth start failed: ${startRes.status} ${body}`);
|
|
143
155
|
}
|
|
144
156
|
const startData = await startRes.json();
|
|
145
157
|
const verificationUri = JUNIS_WEB ? startData.verification_uri.replace(/^https?:\/\/[^/]+/, JUNIS_WEB) : startData.verification_uri;
|
|
@@ -148,7 +160,7 @@ async function authenticate(deviceName, platform2, onBrowserOpen, onWaiting) {
|
|
|
148
160
|
await (0, import_open.default)(verificationUri);
|
|
149
161
|
} catch {
|
|
150
162
|
console.warn(`
|
|
151
|
-
\u26A0\uFE0F
|
|
163
|
+
\u26A0\uFE0F Could not open browser automatically. Please open the following URL manually:
|
|
152
164
|
|
|
153
165
|
${verificationUri}
|
|
154
166
|
`);
|
|
@@ -181,11 +193,11 @@ async function authenticate(deviceName, platform2, onBrowserOpen, onWaiting) {
|
|
|
181
193
|
return result;
|
|
182
194
|
}
|
|
183
195
|
if (pollRes.status === 410) {
|
|
184
|
-
throw new Error("
|
|
196
|
+
throw new Error("Authentication code has expired. Please try again.");
|
|
185
197
|
}
|
|
186
|
-
throw new Error(
|
|
198
|
+
throw new Error(`Unexpected response: ${pollRes.status}`);
|
|
187
199
|
}
|
|
188
|
-
throw new Error("
|
|
200
|
+
throw new Error("Authentication timed out (5 min). Please try again.");
|
|
189
201
|
}
|
|
190
202
|
function sleep(ms) {
|
|
191
203
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -213,25 +225,30 @@ var RelayClient = class {
|
|
|
213
225
|
reconnectDelay = 1e3;
|
|
214
226
|
heartbeatTimer = null;
|
|
215
227
|
destroyed = false;
|
|
228
|
+
lastPongTime = 0;
|
|
216
229
|
async connect() {
|
|
217
230
|
if (this.destroyed) return;
|
|
218
231
|
const url = `${JUNIS_WS}/ws/devices/${this.config.device_key}`;
|
|
219
|
-
console.log(`\u{1F517}
|
|
232
|
+
console.log(`\u{1F517} Connecting to relay server...`);
|
|
220
233
|
const ws = new import_ws.default(url, {
|
|
221
234
|
headers: { Authorization: `Bearer ${this.config.token}` }
|
|
222
235
|
});
|
|
223
236
|
this.ws = ws;
|
|
224
237
|
ws.on("open", () => {
|
|
225
238
|
if (this.ws !== ws) return;
|
|
226
|
-
console.log("\u2705
|
|
239
|
+
console.log("\u2705 Connected to relay server");
|
|
227
240
|
this.reconnectDelay = 1e3;
|
|
241
|
+
this.lastPongTime = Date.now();
|
|
228
242
|
this.startHeartbeat();
|
|
229
243
|
});
|
|
230
244
|
ws.on("message", async (raw) => {
|
|
231
245
|
if (this.ws !== ws) return;
|
|
232
246
|
try {
|
|
233
247
|
const msg = JSON.parse(raw.toString());
|
|
234
|
-
if (msg.type === "pong")
|
|
248
|
+
if (msg.type === "pong") {
|
|
249
|
+
this.lastPongTime = Date.now();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
235
252
|
if (msg.type === "mcp_request") {
|
|
236
253
|
try {
|
|
237
254
|
const result = await this.onMCPRequest(msg.id, msg.payload);
|
|
@@ -257,18 +274,18 @@ var RelayClient = class {
|
|
|
257
274
|
await this.onAuthExpired();
|
|
258
275
|
} else {
|
|
259
276
|
console.error(
|
|
260
|
-
"\n\u274C
|
|
277
|
+
"\n\u274C Auth token expired. Run `npx junis --reset` to re-authenticate."
|
|
261
278
|
);
|
|
262
279
|
process.exit(1);
|
|
263
280
|
}
|
|
264
281
|
return;
|
|
265
282
|
}
|
|
266
|
-
console.log(`\u26A0\uFE0F
|
|
283
|
+
console.log(`\u26A0\uFE0F Disconnected. Reconnecting in ${this.reconnectDelay / 1e3}s...`);
|
|
267
284
|
setTimeout(() => this.connect(), this.reconnectDelay);
|
|
268
285
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 3e4);
|
|
269
286
|
});
|
|
270
287
|
ws.on("error", (err) => {
|
|
271
|
-
console.error(
|
|
288
|
+
console.error(`Relay error: ${err.message}`);
|
|
272
289
|
});
|
|
273
290
|
}
|
|
274
291
|
/** 재인증 완료 후 재연결 */
|
|
@@ -284,6 +301,11 @@ var RelayClient = class {
|
|
|
284
301
|
}
|
|
285
302
|
startHeartbeat() {
|
|
286
303
|
this.heartbeatTimer = setInterval(() => {
|
|
304
|
+
if (Date.now() - this.lastPongTime > 9e4) {
|
|
305
|
+
console.warn("\u26A0\uFE0F Heartbeat timeout (90s no pong). Reconnecting...");
|
|
306
|
+
this.ws?.terminate();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
287
309
|
this.send({ type: "heartbeat" });
|
|
288
310
|
}, 3e4);
|
|
289
311
|
}
|
|
@@ -312,29 +334,96 @@ var import_promises = __toESM(require("fs/promises"));
|
|
|
312
334
|
var import_path2 = __toESM(require("path"));
|
|
313
335
|
var import_glob = require("glob");
|
|
314
336
|
var import_zod = require("zod");
|
|
337
|
+
|
|
338
|
+
// src/server/permissions.ts
|
|
339
|
+
var toolPermissions = {
|
|
340
|
+
// 읽기 전용 — 자동 허용
|
|
341
|
+
browser_snapshot: "auto",
|
|
342
|
+
browser_screenshot: "auto",
|
|
343
|
+
desktop_see: "auto",
|
|
344
|
+
desktop_list_apps: "auto",
|
|
345
|
+
desktop_list_windows: "auto",
|
|
346
|
+
cron_list: "auto",
|
|
347
|
+
read_file: "auto",
|
|
348
|
+
list_directory: "auto",
|
|
349
|
+
list_processes: "auto",
|
|
350
|
+
search_code: "auto",
|
|
351
|
+
// 상호작용 — 확인 권장 (현재: auto와 동일하게 실행, 향후 UI 연동)
|
|
352
|
+
browser_click: "confirm",
|
|
353
|
+
browser_type: "confirm",
|
|
354
|
+
browser_navigate: "confirm",
|
|
355
|
+
browser_fill: "confirm",
|
|
356
|
+
browser_select: "confirm",
|
|
357
|
+
browser_press: "confirm",
|
|
358
|
+
browser_hover: "confirm",
|
|
359
|
+
browser_drag: "confirm",
|
|
360
|
+
browser_upload: "confirm",
|
|
361
|
+
browser_cookies: "confirm",
|
|
362
|
+
browser_storage: "confirm",
|
|
363
|
+
browser_dialog: "confirm",
|
|
364
|
+
desktop_click: "confirm",
|
|
365
|
+
desktop_type: "confirm",
|
|
366
|
+
desktop_hotkey: "confirm",
|
|
367
|
+
desktop_scroll: "confirm",
|
|
368
|
+
desktop_menu: "confirm",
|
|
369
|
+
desktop_screenshot: "confirm",
|
|
370
|
+
cron_create: "confirm",
|
|
371
|
+
cron_delete: "confirm",
|
|
372
|
+
edit_block: "confirm",
|
|
373
|
+
kill_process: "confirm",
|
|
374
|
+
// 시스템 변경 — 기본 차단 (PDF 7.3절)
|
|
375
|
+
execute_command: "deny",
|
|
376
|
+
write_file: "deny"
|
|
377
|
+
};
|
|
378
|
+
function checkPermission(toolName) {
|
|
379
|
+
const level = toolPermissions[toolName];
|
|
380
|
+
if (level === "deny") {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`Tool '${toolName}' is blocked by permission policy (deny). To allow, update toolPermissions in src/server/permissions.ts.`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/tools/filesystem.ts
|
|
315
388
|
var execAsync = (0, import_util.promisify)(import_child_process.exec);
|
|
316
389
|
var execFileAsync = (0, import_util.promisify)(import_child_process.execFile);
|
|
317
390
|
var FilesystemTools = class {
|
|
318
391
|
register(server) {
|
|
319
392
|
server.tool(
|
|
320
393
|
"execute_command",
|
|
321
|
-
|
|
394
|
+
[
|
|
395
|
+
"Execute a shell command on the user's local device.",
|
|
396
|
+
"",
|
|
397
|
+
"ROUTING:",
|
|
398
|
+
"- Use for system commands, package managers (npm, pip, brew), git, build tools, and scripting.",
|
|
399
|
+
"- For reading files prefer read_file, for editing prefer edit_block, for searching prefer search_code.",
|
|
400
|
+
"",
|
|
401
|
+
"BEHAVIOR:",
|
|
402
|
+
"- Safe, routine commands (ls, pwd, git status, echo): execute immediately without explanation.",
|
|
403
|
+
"- Destructive or irreversible commands (rm -rf, sudo, shutdown, mkfs): explain what will happen and get user confirmation first.",
|
|
404
|
+
"- If a command fails, analyze the error and suggest an alternative. Do not retry the identical command more than twice.",
|
|
405
|
+
"",
|
|
406
|
+
"SAFETY:",
|
|
407
|
+
"- Commands run with the user's full permissions. Never execute commands that could damage the system, expose credentials, or modify security settings without explicit user request.",
|
|
408
|
+
"- Avoid piping untrusted input into shells. Use absolute paths when possible. Quote paths containing spaces."
|
|
409
|
+
].join("\n"),
|
|
322
410
|
{
|
|
323
|
-
command: import_zod.z.string().describe("
|
|
324
|
-
timeout_ms: import_zod.z.number().optional().default(3e4).describe("
|
|
325
|
-
background: import_zod.z.boolean().optional().default(false).describe("
|
|
411
|
+
command: import_zod.z.string().describe("The shell command to execute. Use absolute paths when possible. Quote paths containing spaces."),
|
|
412
|
+
timeout_ms: import_zod.z.number().optional().default(3e4).describe("Maximum execution time in milliseconds (default: 30000). Increase for long-running builds or downloads."),
|
|
413
|
+
background: import_zod.z.boolean().optional().default(false).describe("Run in background without waiting for completion. Use for servers or long-running processes.")
|
|
326
414
|
},
|
|
327
415
|
async ({ command, timeout_ms, background }) => {
|
|
416
|
+
checkPermission("execute_command");
|
|
328
417
|
if (background) {
|
|
329
418
|
(0, import_child_process.exec)(command);
|
|
330
|
-
return { content: [{ type: "text", text: "
|
|
419
|
+
return { content: [{ type: "text", text: "Background execution started" }] };
|
|
331
420
|
}
|
|
332
421
|
try {
|
|
333
422
|
const { stdout, stderr } = await execAsync(command, {
|
|
334
423
|
timeout: timeout_ms
|
|
335
424
|
});
|
|
336
425
|
return {
|
|
337
|
-
content: [{ type: "text", text: stdout || stderr || "(
|
|
426
|
+
content: [{ type: "text", text: stdout || stderr || "(no output)" }]
|
|
338
427
|
};
|
|
339
428
|
} catch (err) {
|
|
340
429
|
const error = err;
|
|
@@ -342,7 +431,7 @@ var FilesystemTools = class {
|
|
|
342
431
|
content: [
|
|
343
432
|
{
|
|
344
433
|
type: "text",
|
|
345
|
-
text:
|
|
434
|
+
text: `Error (exit ${error.code ?? "?"}): ${error.message}
|
|
346
435
|
${error.stderr ?? ""}`
|
|
347
436
|
}
|
|
348
437
|
],
|
|
@@ -353,10 +442,15 @@ ${error.stderr ?? ""}`
|
|
|
353
442
|
);
|
|
354
443
|
server.tool(
|
|
355
444
|
"read_file",
|
|
356
|
-
|
|
445
|
+
[
|
|
446
|
+
"Read the contents of a file from the local filesystem.",
|
|
447
|
+
"",
|
|
448
|
+
"Returns file content as text (utf-8) or base64 for binary files. Supports any file type.",
|
|
449
|
+
"For searching within files, prefer search_code instead. For listing directory contents, use list_directory."
|
|
450
|
+
].join("\n"),
|
|
357
451
|
{
|
|
358
|
-
path: import_zod.z.string().describe("
|
|
359
|
-
encoding: import_zod.z.enum(["utf-8", "base64"]).optional().default("utf-8").describe("
|
|
452
|
+
path: import_zod.z.string().describe("Absolute or relative file path to read"),
|
|
453
|
+
encoding: import_zod.z.enum(["utf-8", "base64"]).optional().default("utf-8").describe("'utf-8' for text files (default), 'base64' for binary files (images, PDFs, archives)")
|
|
360
454
|
},
|
|
361
455
|
async ({ path: filePath, encoding }) => {
|
|
362
456
|
try {
|
|
@@ -365,30 +459,39 @@ ${error.stderr ?? ""}`
|
|
|
365
459
|
} catch (err) {
|
|
366
460
|
const e = err;
|
|
367
461
|
if (e.code === "ENOENT") {
|
|
368
|
-
return { content: [{ type: "text", text: `\u274C
|
|
462
|
+
return { content: [{ type: "text", text: `\u274C File not found: ${filePath}` }], isError: true };
|
|
369
463
|
}
|
|
370
|
-
return { content: [{ type: "text", text: `\u274C
|
|
464
|
+
return { content: [{ type: "text", text: `\u274C Failed to read file: ${e.message}` }], isError: true };
|
|
371
465
|
}
|
|
372
466
|
}
|
|
373
467
|
);
|
|
374
468
|
server.tool(
|
|
375
469
|
"write_file",
|
|
376
|
-
|
|
470
|
+
[
|
|
471
|
+
"Create a new file or completely overwrite an existing file. Parent directories are created automatically.",
|
|
472
|
+
"",
|
|
473
|
+
"WARNING: This replaces the entire file content. For partial modifications, use edit_block instead.",
|
|
474
|
+
"Prefer edit_block over write_file for existing files \u2014 it's safer and preserves unmodified content."
|
|
475
|
+
].join("\n"),
|
|
377
476
|
{
|
|
378
|
-
path: import_zod.z.string().describe("
|
|
379
|
-
content: import_zod.z.string().describe("
|
|
477
|
+
path: import_zod.z.string().describe("File path to create or overwrite. Parent directories are auto-created."),
|
|
478
|
+
content: import_zod.z.string().describe("Complete file content. This replaces the entire file.")
|
|
380
479
|
},
|
|
381
480
|
async ({ path: filePath, content }) => {
|
|
481
|
+
checkPermission("write_file");
|
|
382
482
|
await import_promises.default.mkdir(import_path2.default.dirname(filePath), { recursive: true });
|
|
383
483
|
await import_promises.default.writeFile(filePath, content, "utf-8");
|
|
384
|
-
return { content: [{ type: "text", text: "
|
|
484
|
+
return { content: [{ type: "text", text: "File saved" }] };
|
|
385
485
|
}
|
|
386
486
|
);
|
|
387
487
|
server.tool(
|
|
388
488
|
"list_directory",
|
|
389
|
-
|
|
489
|
+
[
|
|
490
|
+
"List files and subdirectories in the specified path. Returns entries with type indicators (\u{1F4C1} directory, \u{1F4C4} file).",
|
|
491
|
+
"Use this to explore project structure before reading or modifying files."
|
|
492
|
+
].join("\n"),
|
|
390
493
|
{
|
|
391
|
-
path: import_zod.z.string().describe("
|
|
494
|
+
path: import_zod.z.string().describe("Directory path to list")
|
|
392
495
|
},
|
|
393
496
|
async ({ path: dirPath }) => {
|
|
394
497
|
try {
|
|
@@ -398,19 +501,24 @@ ${error.stderr ?? ""}`
|
|
|
398
501
|
} catch (err) {
|
|
399
502
|
const e = err;
|
|
400
503
|
if (e.code === "ENOENT") {
|
|
401
|
-
return { content: [{ type: "text", text: `\u274C
|
|
504
|
+
return { content: [{ type: "text", text: `\u274C Directory not found: ${dirPath}` }], isError: true };
|
|
402
505
|
}
|
|
403
|
-
return { content: [{ type: "text", text: `\u274C
|
|
506
|
+
return { content: [{ type: "text", text: `\u274C Failed to read directory: ${e.message}` }], isError: true };
|
|
404
507
|
}
|
|
405
508
|
}
|
|
406
509
|
);
|
|
407
510
|
server.tool(
|
|
408
511
|
"search_code",
|
|
409
|
-
|
|
512
|
+
[
|
|
513
|
+
"Search for text patterns across files using regex. Uses ripgrep for speed with glob fallback.",
|
|
514
|
+
"",
|
|
515
|
+
"Use this to find code definitions, function references, configuration values, or any text pattern.",
|
|
516
|
+
"Returns matching lines with file paths and line numbers for precise navigation."
|
|
517
|
+
].join("\n"),
|
|
410
518
|
{
|
|
411
|
-
pattern: import_zod.z.string().describe("
|
|
412
|
-
directory: import_zod.z.string().optional().default(".").describe("
|
|
413
|
-
file_pattern: import_zod.z.string().optional().default("**/*").describe("
|
|
519
|
+
pattern: import_zod.z.string().describe("Search pattern with full regex support (e.g. 'function\\s+\\w+', 'import.*from', 'TODO')"),
|
|
520
|
+
directory: import_zod.z.string().optional().default(".").describe("Root directory to search from (default: current working directory)"),
|
|
521
|
+
file_pattern: import_zod.z.string().optional().default("**/*").describe("Glob pattern to filter files (e.g. '**/*.ts', '*.py', 'src/**/*.js')")
|
|
414
522
|
},
|
|
415
523
|
async ({ pattern, directory, file_pattern }) => {
|
|
416
524
|
try {
|
|
@@ -419,7 +527,7 @@ ${error.stderr ?? ""}`
|
|
|
419
527
|
["--no-heading", "-n", pattern, directory],
|
|
420
528
|
{ timeout: 1e4 }
|
|
421
529
|
);
|
|
422
|
-
return { content: [{ type: "text", text: stdout || "
|
|
530
|
+
return { content: [{ type: "text", text: stdout || "No results" }] };
|
|
423
531
|
} catch {
|
|
424
532
|
const safeDirectory = import_path2.default.resolve(directory);
|
|
425
533
|
const files = await (0, import_glob.glob)(file_pattern, { cwd: safeDirectory });
|
|
@@ -440,7 +548,7 @@ ${error.stderr ?? ""}`
|
|
|
440
548
|
}
|
|
441
549
|
return {
|
|
442
550
|
content: [
|
|
443
|
-
{ type: "text", text: results.join("\n") || "
|
|
551
|
+
{ type: "text", text: results.join("\n") || "No results" }
|
|
444
552
|
]
|
|
445
553
|
};
|
|
446
554
|
}
|
|
@@ -448,7 +556,7 @@ ${error.stderr ?? ""}`
|
|
|
448
556
|
);
|
|
449
557
|
server.tool(
|
|
450
558
|
"list_processes",
|
|
451
|
-
"
|
|
559
|
+
"List the top 30 running processes sorted by CPU usage. Use this to identify resource-heavy processes, find PIDs for kill_process, or diagnose performance issues.",
|
|
452
560
|
{},
|
|
453
561
|
async () => {
|
|
454
562
|
const cmd = process.platform === "win32" ? "tasklist" : process.platform === "darwin" ? "ps aux | sort -rk 3 | head -30" : "ps aux --sort=-%cpu | head -30";
|
|
@@ -458,23 +566,27 @@ ${error.stderr ?? ""}`
|
|
|
458
566
|
);
|
|
459
567
|
server.tool(
|
|
460
568
|
"kill_process",
|
|
461
|
-
|
|
569
|
+
[
|
|
570
|
+
"Terminate a process by PID. Default: sends SIGTERM (graceful shutdown), waits 3 seconds, then auto-applies SIGKILL if still alive.",
|
|
571
|
+
"",
|
|
572
|
+
"SAFETY: Only kill processes the user explicitly identifies. Never kill system-critical processes (init, systemd, loginwindow, WindowServer) without explicit instruction."
|
|
573
|
+
].join("\n"),
|
|
462
574
|
{
|
|
463
|
-
pid: import_zod.z.number().describe("
|
|
464
|
-
signal: import_zod.z.enum(["SIGTERM", "SIGKILL"]).optional().default("SIGTERM").describe("
|
|
575
|
+
pid: import_zod.z.number().describe("PID of the process to terminate (use list_processes to find PIDs)"),
|
|
576
|
+
signal: import_zod.z.enum(["SIGTERM", "SIGKILL"]).optional().default("SIGTERM").describe("SIGTERM (default): graceful shutdown with 3s auto-SIGKILL fallback. SIGKILL: immediate force kill.")
|
|
465
577
|
},
|
|
466
578
|
async ({ pid, signal }) => {
|
|
467
579
|
const isWindows = process.platform === "win32";
|
|
468
580
|
if (isWindows) {
|
|
469
581
|
await execAsync(`taskkill /PID ${pid} /F`);
|
|
470
582
|
return {
|
|
471
|
-
content: [{ type: "text", text: `PID ${pid}
|
|
583
|
+
content: [{ type: "text", text: `PID ${pid} killed (taskkill /F)` }]
|
|
472
584
|
};
|
|
473
585
|
}
|
|
474
586
|
if (signal === "SIGKILL") {
|
|
475
587
|
await execAsync(`kill -9 ${pid}`);
|
|
476
588
|
return {
|
|
477
|
-
content: [{ type: "text", text: `PID ${pid}
|
|
589
|
+
content: [{ type: "text", text: `PID ${pid} force killed (SIGKILL)` }]
|
|
478
590
|
};
|
|
479
591
|
}
|
|
480
592
|
try {
|
|
@@ -482,7 +594,7 @@ ${error.stderr ?? ""}`
|
|
|
482
594
|
} catch {
|
|
483
595
|
return {
|
|
484
596
|
content: [
|
|
485
|
-
{ type: "text", text: `PID ${pid}
|
|
597
|
+
{ type: "text", text: `PID ${pid} kill failed: process does not exist or permission denied.` }
|
|
486
598
|
],
|
|
487
599
|
isError: true
|
|
488
600
|
};
|
|
@@ -491,7 +603,7 @@ ${error.stderr ?? ""}`
|
|
|
491
603
|
const isAlive = await execAsync(`kill -0 ${pid}`).then(() => true).catch(() => false);
|
|
492
604
|
if (!isAlive) {
|
|
493
605
|
return {
|
|
494
|
-
content: [{ type: "text", text: `PID ${pid}
|
|
606
|
+
content: [{ type: "text", text: `PID ${pid} killed (SIGTERM)` }]
|
|
495
607
|
};
|
|
496
608
|
}
|
|
497
609
|
await execAsync(`kill -9 ${pid}`);
|
|
@@ -499,7 +611,7 @@ ${error.stderr ?? ""}`
|
|
|
499
611
|
content: [
|
|
500
612
|
{
|
|
501
613
|
type: "text",
|
|
502
|
-
text: `PID ${pid}
|
|
614
|
+
text: `PID ${pid} force killed (SIGTERM unresponsive, auto SIGKILL applied)`
|
|
503
615
|
}
|
|
504
616
|
]
|
|
505
617
|
};
|
|
@@ -507,17 +619,25 @@ ${error.stderr ?? ""}`
|
|
|
507
619
|
);
|
|
508
620
|
server.tool(
|
|
509
621
|
"edit_block",
|
|
510
|
-
|
|
622
|
+
[
|
|
623
|
+
"Replace a specific text block in a file with new text (diff-based partial edit).",
|
|
624
|
+
"",
|
|
625
|
+
"WORKFLOW: Always use read_file first to see current content, then use edit_block with the exact text to replace.",
|
|
626
|
+
"The old_string must match character-for-character including whitespace, indentation, and line breaks.",
|
|
627
|
+
"If multiple matches exist, include more surrounding context to make it unique, or set replace_all=true.",
|
|
628
|
+
"",
|
|
629
|
+
"Prefer this over write_file for modifying existing files \u2014 it only changes what you specify and preserves the rest."
|
|
630
|
+
].join("\n"),
|
|
511
631
|
{
|
|
512
|
-
path: import_zod.z.string().describe("
|
|
513
|
-
old_string: import_zod.z.string().describe("
|
|
514
|
-
new_string: import_zod.z.string().describe("
|
|
515
|
-
replace_all: import_zod.z.boolean().optional().default(false).describe("true
|
|
632
|
+
path: import_zod.z.string().describe("Path to the file to edit. The file must already exist."),
|
|
633
|
+
old_string: import_zod.z.string().describe("The exact text to find and replace. Must match character-for-character including whitespace and newlines. Include enough context for uniqueness."),
|
|
634
|
+
new_string: import_zod.z.string().describe("The replacement text. Use empty string to delete the matched text."),
|
|
635
|
+
replace_all: import_zod.z.boolean().optional().default(false).describe("If true, replace ALL matches. If false (default), require exactly one match (errors on ambiguous multiple matches).")
|
|
516
636
|
},
|
|
517
637
|
async ({ path: filePath, old_string, new_string, replace_all }) => {
|
|
518
638
|
const content = await import_promises.default.readFile(filePath, "utf-8");
|
|
519
639
|
if (!content.includes(old_string)) {
|
|
520
|
-
throw new Error(`old_string
|
|
640
|
+
throw new Error(`old_string not found in file: ${filePath}`);
|
|
521
641
|
}
|
|
522
642
|
let count = 0;
|
|
523
643
|
let pos = 0;
|
|
@@ -527,7 +647,7 @@ ${error.stderr ?? ""}`
|
|
|
527
647
|
}
|
|
528
648
|
if (!replace_all && count > 1) {
|
|
529
649
|
throw new Error(
|
|
530
|
-
|
|
650
|
+
`Found ${count} matches. Set replace_all to true or include more context to narrow it down.`
|
|
531
651
|
);
|
|
532
652
|
}
|
|
533
653
|
let result;
|
|
@@ -541,21 +661,202 @@ ${error.stderr ?? ""}`
|
|
|
541
661
|
}
|
|
542
662
|
await import_promises.default.writeFile(filePath, result, "utf-8");
|
|
543
663
|
return {
|
|
544
|
-
content: [{ type: "text", text:
|
|
664
|
+
content: [{ type: "text", text: `Replaced (${replaced} occurrence(s) changed)` }]
|
|
545
665
|
};
|
|
546
666
|
}
|
|
547
667
|
);
|
|
668
|
+
server.tool(
|
|
669
|
+
"cron_create",
|
|
670
|
+
[
|
|
671
|
+
"Create a recurring scheduled task (cron job) using standard cron syntax.",
|
|
672
|
+
"",
|
|
673
|
+
"Common schedules: '*/5 * * * *' (every 5 min), '0 9 * * 1-5' (weekdays 9am), '0 0 * * *' (daily midnight), '0 */2 * * *' (every 2 hours).",
|
|
674
|
+
"Duplicate commands are automatically detected and rejected. Use cron_list to see existing jobs."
|
|
675
|
+
].join("\n"),
|
|
676
|
+
{
|
|
677
|
+
schedule: import_zod.z.string().describe("Cron schedule expression (5 fields: minute hour day month weekday). Examples: '*/5 * * * *' (every 5 min), '0 9 * * 1-5' (weekdays 9am)"),
|
|
678
|
+
command: import_zod.z.string().describe("Shell command to execute on schedule"),
|
|
679
|
+
label: import_zod.z.string().optional().describe("Human-readable label for identification (e.g. 'daily-backup', 'log-cleanup')")
|
|
680
|
+
},
|
|
681
|
+
async ({ schedule, command, label }) => {
|
|
682
|
+
try {
|
|
683
|
+
let existing = "";
|
|
684
|
+
try {
|
|
685
|
+
const { stdout } = await execAsync("crontab -l");
|
|
686
|
+
existing = stdout;
|
|
687
|
+
} catch {
|
|
688
|
+
}
|
|
689
|
+
if (existing.includes(command)) {
|
|
690
|
+
return {
|
|
691
|
+
content: [{ type: "text", text: `\u26A0\uFE0F A cron job with this command already exists.` }],
|
|
692
|
+
isError: true
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
const comment = label ? `# junis:${label}
|
|
696
|
+
` : "# junis-cron\n";
|
|
697
|
+
const newEntry = `${comment}${schedule} ${command}
|
|
698
|
+
`;
|
|
699
|
+
const updated = existing.trimEnd() + "\n" + newEntry;
|
|
700
|
+
const tmpFile = `/tmp/junis_crontab_${Date.now()}.txt`;
|
|
701
|
+
await import_promises.default.writeFile(tmpFile, updated, "utf-8");
|
|
702
|
+
await execAsync(`crontab ${tmpFile}`);
|
|
703
|
+
await import_promises.default.unlink(tmpFile).catch(() => {
|
|
704
|
+
});
|
|
705
|
+
return {
|
|
706
|
+
content: [{ type: "text", text: `\u2705 Cron job created:
|
|
707
|
+
schedule: ${schedule}
|
|
708
|
+
command: ${command}${label ? `
|
|
709
|
+
label: ${label}` : ""}` }]
|
|
710
|
+
};
|
|
711
|
+
} catch (err) {
|
|
712
|
+
return {
|
|
713
|
+
content: [{ type: "text", text: `\u274C Failed to create cron job: ${err.message}` }],
|
|
714
|
+
isError: true
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
);
|
|
719
|
+
server.tool(
|
|
720
|
+
"cron_list",
|
|
721
|
+
"List all scheduled cron jobs with their IDs, labels, schedules, and commands. Use the returned ID numbers with cron_delete to remove specific jobs.",
|
|
722
|
+
{},
|
|
723
|
+
async () => {
|
|
724
|
+
try {
|
|
725
|
+
const { stdout } = await execAsync("crontab -l");
|
|
726
|
+
const lines = stdout.trim().split("\n").filter((l) => l.trim());
|
|
727
|
+
if (lines.length === 0) {
|
|
728
|
+
return { content: [{ type: "text", text: "No cron jobs found." }] };
|
|
729
|
+
}
|
|
730
|
+
const entries = [];
|
|
731
|
+
let pendingLabel;
|
|
732
|
+
let id = 1;
|
|
733
|
+
for (const line of lines) {
|
|
734
|
+
if (line.startsWith("#")) {
|
|
735
|
+
const match = line.match(/^# junis:(.+)$/);
|
|
736
|
+
pendingLabel = match ? match[1].trim() : void 0;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const parts = line.split(/\s+/);
|
|
740
|
+
if (parts.length >= 6) {
|
|
741
|
+
const schedule = parts.slice(0, 5).join(" ");
|
|
742
|
+
const command = parts.slice(5).join(" ");
|
|
743
|
+
entries.push({ id: id++, label: pendingLabel, schedule, command });
|
|
744
|
+
}
|
|
745
|
+
pendingLabel = void 0;
|
|
746
|
+
}
|
|
747
|
+
if (entries.length === 0) {
|
|
748
|
+
return { content: [{ type: "text", text: stdout }] };
|
|
749
|
+
}
|
|
750
|
+
const output = entries.map(
|
|
751
|
+
(e) => `[${e.id}] ${e.label ? `(${e.label}) ` : ""}${e.schedule} \u2192 ${e.command}`
|
|
752
|
+
).join("\n");
|
|
753
|
+
return { content: [{ type: "text", text: output }] };
|
|
754
|
+
} catch (err) {
|
|
755
|
+
const e = err;
|
|
756
|
+
if (e.code === 1) {
|
|
757
|
+
return { content: [{ type: "text", text: "No cron jobs found (crontab is empty)." }] };
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
content: [{ type: "text", text: `\u274C Failed to list cron jobs: ${e.message}` }],
|
|
761
|
+
isError: true
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
);
|
|
766
|
+
server.tool(
|
|
767
|
+
"cron_delete",
|
|
768
|
+
"Delete a scheduled cron job by its ID (from cron_list output) or by matching command string. Associated comment labels are automatically cleaned up.",
|
|
769
|
+
{
|
|
770
|
+
id: import_zod.z.number().optional().describe("Cron job ID from cron_list output (e.g. 1, 2, 3)"),
|
|
771
|
+
command: import_zod.z.string().optional().describe("Delete all jobs matching this command string")
|
|
772
|
+
},
|
|
773
|
+
async ({ id, command }) => {
|
|
774
|
+
if (!id && !command) {
|
|
775
|
+
return {
|
|
776
|
+
content: [{ type: "text", text: "\u274C Provide either id or command to identify the cron job." }],
|
|
777
|
+
isError: true
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
let existing = "";
|
|
782
|
+
try {
|
|
783
|
+
const { stdout } = await execAsync("crontab -l");
|
|
784
|
+
existing = stdout;
|
|
785
|
+
} catch {
|
|
786
|
+
return { content: [{ type: "text", text: "No cron jobs to delete." }] };
|
|
787
|
+
}
|
|
788
|
+
const lines = existing.split("\n");
|
|
789
|
+
if (command) {
|
|
790
|
+
const filtered2 = [];
|
|
791
|
+
for (let i = 0; i < lines.length; i++) {
|
|
792
|
+
if (lines[i].includes(command)) {
|
|
793
|
+
if (filtered2.length > 0 && filtered2[filtered2.length - 1].trim().startsWith("#")) {
|
|
794
|
+
filtered2.pop();
|
|
795
|
+
}
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
filtered2.push(lines[i]);
|
|
799
|
+
}
|
|
800
|
+
if (filtered2.length === lines.length) {
|
|
801
|
+
return {
|
|
802
|
+
content: [{ type: "text", text: `\u274C No cron job found matching: ${command}` }],
|
|
803
|
+
isError: true
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
const updated2 = filtered2.join("\n");
|
|
807
|
+
const tmpFile2 = `/tmp/junis_crontab_${Date.now()}.txt`;
|
|
808
|
+
await import_promises.default.writeFile(tmpFile2, updated2, "utf-8");
|
|
809
|
+
await execAsync(`crontab ${tmpFile2}`);
|
|
810
|
+
await import_promises.default.unlink(tmpFile2).catch(() => {
|
|
811
|
+
});
|
|
812
|
+
return { content: [{ type: "text", text: `\u2705 Deleted cron job matching: ${command}` }] };
|
|
813
|
+
}
|
|
814
|
+
const entries = [];
|
|
815
|
+
let idx = 1;
|
|
816
|
+
for (let i = 0; i < lines.length; i++) {
|
|
817
|
+
const line = lines[i].trim();
|
|
818
|
+
if (line.startsWith("#")) continue;
|
|
819
|
+
const parts = line.split(/\s+/);
|
|
820
|
+
if (parts.length >= 6) {
|
|
821
|
+
const prevIsComment = i > 0 && lines[i - 1].trim().startsWith("#");
|
|
822
|
+
entries.push({ lineStart: prevIsComment ? i - 1 : i, lineEnd: i, idx: idx++ });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const target = entries.find((e) => e.idx === id);
|
|
826
|
+
if (!target) {
|
|
827
|
+
return {
|
|
828
|
+
content: [{ type: "text", text: `\u274C No cron job found with id=${id}. Use cron_list to see current IDs.` }],
|
|
829
|
+
isError: true
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
const filtered = lines.filter((_, i) => i < target.lineStart || i > target.lineEnd);
|
|
833
|
+
const updated = filtered.join("\n");
|
|
834
|
+
const tmpFile = `/tmp/junis_crontab_${Date.now()}.txt`;
|
|
835
|
+
await import_promises.default.writeFile(tmpFile, updated, "utf-8");
|
|
836
|
+
await execAsync(`crontab ${tmpFile}`);
|
|
837
|
+
await import_promises.default.unlink(tmpFile).catch(() => {
|
|
838
|
+
});
|
|
839
|
+
return { content: [{ type: "text", text: `\u2705 Deleted cron job #${id}` }] };
|
|
840
|
+
} catch (err) {
|
|
841
|
+
return {
|
|
842
|
+
content: [{ type: "text", text: `\u274C Failed to delete cron job: ${err.message}` }],
|
|
843
|
+
isError: true
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
);
|
|
548
848
|
}
|
|
549
849
|
};
|
|
550
850
|
|
|
551
851
|
// src/tools/browser.ts
|
|
552
|
-
var
|
|
852
|
+
var import_browserclaw = require("browserclaw");
|
|
853
|
+
var import_promises2 = __toESM(require("fs/promises"));
|
|
553
854
|
var import_zod2 = require("zod");
|
|
554
855
|
var BrowserTools = class {
|
|
555
856
|
browser = null;
|
|
556
857
|
page = null;
|
|
557
|
-
// 동시 요청 시 race condition 방지용 직렬화 락
|
|
558
858
|
lock = Promise.resolve();
|
|
859
|
+
armedDialog = null;
|
|
559
860
|
withLock(fn) {
|
|
560
861
|
let release;
|
|
561
862
|
const next = new Promise((r) => {
|
|
@@ -565,128 +866,403 @@ var BrowserTools = class {
|
|
|
565
866
|
this.lock = this.lock.then(() => next);
|
|
566
867
|
return current.then(() => fn()).finally(() => release());
|
|
567
868
|
}
|
|
869
|
+
/** mcp.ts에서 호출하는 init — BrowserClaw는 browser_start 도구로 명시적 시작하므로 noop */
|
|
568
870
|
async init() {
|
|
569
|
-
try {
|
|
570
|
-
this.browser = await import_playwright.chromium.launch({ headless: true });
|
|
571
|
-
this.page = await this.browser.newPage();
|
|
572
|
-
} catch {
|
|
573
|
-
console.warn(
|
|
574
|
-
"\u26A0\uFE0F Playwright \uBBF8\uC124\uCE58. \uBE0C\uB77C\uC6B0\uC800 \uB3C4\uAD6C \uBE44\uD65C\uC131\uD654.\n \uD65C\uC131\uD654: npx playwright install chromium"
|
|
575
|
-
);
|
|
576
|
-
}
|
|
577
871
|
}
|
|
578
872
|
async cleanup() {
|
|
579
|
-
await this.browser?.
|
|
873
|
+
await this.browser?.stop();
|
|
874
|
+
this.browser = null;
|
|
875
|
+
this.page = null;
|
|
580
876
|
}
|
|
581
877
|
register(server) {
|
|
582
878
|
const requirePage = () => {
|
|
583
|
-
if (!this.page) throw new Error("
|
|
879
|
+
if (!this.page) throw new Error("Browser not started. Call browser_start first.");
|
|
584
880
|
return this.page;
|
|
585
881
|
};
|
|
882
|
+
server.tool(
|
|
883
|
+
"browser_start",
|
|
884
|
+
[
|
|
885
|
+
"Launch or connect to a web browser for automation.",
|
|
886
|
+
"",
|
|
887
|
+
"MODES:",
|
|
888
|
+
"- 'managed' (default): Launches a new Chromium instance. Use 'headless' for background operation, 'profile' for persistent sessions (cookies, logins preserved).",
|
|
889
|
+
"- 'remote-cdp': Connects to an already-running Chrome via CDP URL (e.g. from chrome://inspect). Use this to automate an existing browser session.",
|
|
890
|
+
"",
|
|
891
|
+
"WORKFLOW: browser_start \u2192 browser_navigate \u2192 browser_snapshot \u2192 interact (click/type/fill) \u2192 browser_stop.",
|
|
892
|
+
"Always call browser_stop when done to release system resources."
|
|
893
|
+
].join("\n"),
|
|
894
|
+
{
|
|
895
|
+
mode: import_zod2.z.enum(["managed", "remote-cdp"]).optional().default("managed").describe("'managed' = launch new browser, 'remote-cdp' = connect to existing Chrome via CDP"),
|
|
896
|
+
headless: import_zod2.z.boolean().optional().default(false).describe("Run without visible window (managed mode only). Use for background tasks."),
|
|
897
|
+
cdpUrl: import_zod2.z.string().optional().describe("Chrome DevTools Protocol URL for remote-cdp mode (e.g. http://localhost:9222)"),
|
|
898
|
+
profile: import_zod2.z.string().optional().describe("Browser profile name for persistent sessions \u2014 preserves cookies, logins, and history across restarts (managed mode only)"),
|
|
899
|
+
allowInternal: import_zod2.z.boolean().optional().default(false).describe("Allow navigation to localhost and internal network URLs")
|
|
900
|
+
},
|
|
901
|
+
({ mode, headless, cdpUrl, profile, allowInternal }) => this.withLock(async () => {
|
|
902
|
+
if (this.browser) {
|
|
903
|
+
return { content: [{ type: "text", text: "Browser is already running. Call browser_stop first." }] };
|
|
904
|
+
}
|
|
905
|
+
if (mode === "remote-cdp") {
|
|
906
|
+
if (!cdpUrl) throw new Error("cdpUrl is required for remote-cdp mode");
|
|
907
|
+
this.browser = await import_browserclaw.BrowserClaw.connect(cdpUrl, { allowInternal });
|
|
908
|
+
} else {
|
|
909
|
+
this.browser = await import_browserclaw.BrowserClaw.launch({
|
|
910
|
+
headless,
|
|
911
|
+
profileName: profile,
|
|
912
|
+
allowInternal
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
return { content: [{ type: "text", text: `Browser started (mode: ${mode})` }] };
|
|
916
|
+
})
|
|
917
|
+
);
|
|
918
|
+
server.tool(
|
|
919
|
+
"browser_stop",
|
|
920
|
+
"Stop the browser and release all associated resources (memory, connections, processes). Always call this when browser automation is complete.",
|
|
921
|
+
{},
|
|
922
|
+
() => this.withLock(async () => {
|
|
923
|
+
await this.cleanup();
|
|
924
|
+
return { content: [{ type: "text", text: "Browser stopped" }] };
|
|
925
|
+
})
|
|
926
|
+
);
|
|
586
927
|
server.tool(
|
|
587
928
|
"browser_navigate",
|
|
588
|
-
"URL
|
|
589
|
-
{
|
|
929
|
+
"Navigate the browser to a URL. Automatically opens a new tab if the browser is started but no page exists yet. Waits for the page to load before returning.",
|
|
930
|
+
{
|
|
931
|
+
url: import_zod2.z.string().describe("Full URL to navigate to (include https://)")
|
|
932
|
+
},
|
|
590
933
|
({ url }) => this.withLock(async () => {
|
|
591
|
-
|
|
592
|
-
|
|
934
|
+
if (!this.browser) throw new Error("Browser not started. Call browser_start first.");
|
|
935
|
+
if (!this.page) {
|
|
936
|
+
this.page = await this.browser.open(url);
|
|
937
|
+
} else {
|
|
938
|
+
await this.page.goto(url);
|
|
939
|
+
}
|
|
940
|
+
const currentUrl = await this.page.url();
|
|
941
|
+
return { content: [{ type: "text", text: `Navigated to: ${currentUrl}` }] };
|
|
942
|
+
})
|
|
943
|
+
);
|
|
944
|
+
server.tool(
|
|
945
|
+
"browser_snapshot",
|
|
946
|
+
[
|
|
947
|
+
"Capture the page's Accessibility Tree with numbered ref IDs for each element. This is the primary way to 'see' and understand page content.",
|
|
948
|
+
"",
|
|
949
|
+
"WORKFLOW: Call browser_snapshot \u2192 find the target element's ref (e.g. 'e1', 'e5') \u2192 use that ref in browser_click, browser_type, or other interaction tools.",
|
|
950
|
+
"Refs change after page updates \u2014 always call browser_snapshot again after navigation or clicks that modify the page.",
|
|
951
|
+
"",
|
|
952
|
+
"Prefer this over browser_screenshot for understanding page structure \u2014 it's faster, structured, and machine-readable."
|
|
953
|
+
].join("\n"),
|
|
954
|
+
{
|
|
955
|
+
interactive: import_zod2.z.boolean().optional().default(true).describe("true (default): only show clickable/typeable elements. false: show all elements including static text."),
|
|
956
|
+
compact: import_zod2.z.boolean().optional().default(true).describe("true (default): hide empty containers for cleaner output")
|
|
957
|
+
},
|
|
958
|
+
({ interactive, compact }) => this.withLock(async () => {
|
|
959
|
+
const result = await requirePage().snapshot({ interactive, compact });
|
|
960
|
+
const { snapshot, refs, stats } = result;
|
|
961
|
+
const refList = Object.entries(refs).map(([r, info]) => ` ${r}: ${info.role} "${info.name ?? ""}"`).join("\n");
|
|
962
|
+
const total = stats?.refs ?? Object.keys(refs).length;
|
|
593
963
|
return {
|
|
594
|
-
content: [{
|
|
964
|
+
content: [{
|
|
965
|
+
type: "text",
|
|
966
|
+
text: `${snapshot}
|
|
967
|
+
|
|
968
|
+
--- refs (${total} total) ---
|
|
969
|
+
${refList}`
|
|
970
|
+
}]
|
|
595
971
|
};
|
|
596
972
|
})
|
|
597
973
|
);
|
|
598
974
|
server.tool(
|
|
599
975
|
"browser_click",
|
|
600
|
-
"\
|
|
601
|
-
{
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
976
|
+
"Click an element by its ref number from browser_snapshot. Always call browser_snapshot first to get current refs \u2014 they change after page updates.",
|
|
977
|
+
{
|
|
978
|
+
ref: import_zod2.z.string().describe("Element ref from browser_snapshot (e.g. 'e1', 'e15'). Call browser_snapshot first to get current refs."),
|
|
979
|
+
doubleClick: import_zod2.z.boolean().optional().default(false).describe("Double-click instead of single click"),
|
|
980
|
+
button: import_zod2.z.enum(["left", "right", "middle"]).optional().default("left").describe("Mouse button to use")
|
|
981
|
+
},
|
|
982
|
+
({ ref, doubleClick, button }) => this.withLock(async () => {
|
|
983
|
+
await requirePage().click(ref, { doubleClick, button });
|
|
984
|
+
return { content: [{ type: "text", text: `Clicked ref=${ref}` }] };
|
|
605
985
|
})
|
|
606
986
|
);
|
|
607
987
|
server.tool(
|
|
608
988
|
"browser_type",
|
|
609
|
-
"
|
|
989
|
+
"Type text into an input element by ref number. Use 'submit=true' to press Enter after typing (e.g. for search forms). Use 'slowly=true' for sites requiring keystroke-by-keystroke input.",
|
|
610
990
|
{
|
|
611
|
-
|
|
612
|
-
text: import_zod2.z.string().describe("
|
|
613
|
-
|
|
991
|
+
ref: import_zod2.z.string().describe("Element ref from browser_snapshot (e.g. 'e3')"),
|
|
992
|
+
text: import_zod2.z.string().describe("Text to type into the element"),
|
|
993
|
+
submit: import_zod2.z.boolean().optional().default(false).describe("Press Enter after typing (useful for search boxes and forms)"),
|
|
994
|
+
slowly: import_zod2.z.boolean().optional().default(false).describe("Type slowly (75ms per char) for sites that process each keystroke")
|
|
614
995
|
},
|
|
615
|
-
({
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
996
|
+
({ ref, text, submit, slowly }) => this.withLock(async () => {
|
|
997
|
+
await requirePage().type(ref, text, { submit, slowly });
|
|
998
|
+
return { content: [{ type: "text", text: `Typed into ref=${ref}` }] };
|
|
999
|
+
})
|
|
1000
|
+
);
|
|
1001
|
+
server.tool(
|
|
1002
|
+
"browser_fill",
|
|
1003
|
+
"Fill multiple form fields at once \u2014 more efficient than calling browser_type repeatedly. Each field needs a ref from browser_snapshot.",
|
|
1004
|
+
{
|
|
1005
|
+
fields: import_zod2.z.array(import_zod2.z.object({
|
|
1006
|
+
ref: import_zod2.z.string(),
|
|
1007
|
+
type: import_zod2.z.enum(["text", "checkbox", "radio"]),
|
|
1008
|
+
value: import_zod2.z.union([import_zod2.z.string(), import_zod2.z.boolean()])
|
|
1009
|
+
})).describe("Array of {ref, type, value}. type='text': value is string. type='checkbox'/'radio': value is boolean.")
|
|
1010
|
+
},
|
|
1011
|
+
({ fields }) => this.withLock(async () => {
|
|
1012
|
+
await requirePage().fill(fields);
|
|
1013
|
+
return { content: [{ type: "text", text: `Filled ${fields.length} field(s)` }] };
|
|
1014
|
+
})
|
|
1015
|
+
);
|
|
1016
|
+
server.tool(
|
|
1017
|
+
"browser_select",
|
|
1018
|
+
"Select one or more options from a dropdown/select element. Values should match the option value attributes, not display text.",
|
|
1019
|
+
{
|
|
1020
|
+
ref: import_zod2.z.string().describe("Ref of the <select> element from browser_snapshot"),
|
|
1021
|
+
values: import_zod2.z.array(import_zod2.z.string()).describe("Option value(s) to select")
|
|
1022
|
+
},
|
|
1023
|
+
({ ref, values }) => this.withLock(async () => {
|
|
1024
|
+
await requirePage().select(ref, ...values);
|
|
1025
|
+
return { content: [{ type: "text", text: `Selected option(s) in ref=${ref}` }] };
|
|
1026
|
+
})
|
|
1027
|
+
);
|
|
1028
|
+
server.tool(
|
|
1029
|
+
"browser_press",
|
|
1030
|
+
"Press a keyboard key or key combination. Use for shortcuts (e.g. 'Control+a', 'Escape'), form submission ('Enter'), or navigation ('Tab'). Does not require a specific element ref.",
|
|
1031
|
+
{
|
|
1032
|
+
key: import_zod2.z.string().describe("Key or combination: 'Enter', 'Escape', 'Tab', 'Control+a', 'Meta+c', 'ArrowDown', 'Backspace'")
|
|
1033
|
+
},
|
|
1034
|
+
({ key }) => this.withLock(async () => {
|
|
1035
|
+
await requirePage().press(key);
|
|
1036
|
+
return { content: [{ type: "text", text: `Pressed: ${key}` }] };
|
|
1037
|
+
})
|
|
1038
|
+
);
|
|
1039
|
+
server.tool(
|
|
1040
|
+
"browser_hover",
|
|
1041
|
+
"Move the mouse cursor over an element by ref. Use to trigger hover menus, tooltips, or dropdown previews before clicking.",
|
|
1042
|
+
{
|
|
1043
|
+
ref: import_zod2.z.string().describe("Element ref from browser_snapshot")
|
|
1044
|
+
},
|
|
1045
|
+
({ ref }) => this.withLock(async () => {
|
|
1046
|
+
await requirePage().hover(ref);
|
|
1047
|
+
return { content: [{ type: "text", text: `Hovered over ref=${ref}` }] };
|
|
1048
|
+
})
|
|
1049
|
+
);
|
|
1050
|
+
server.tool(
|
|
1051
|
+
"browser_drag",
|
|
1052
|
+
"Drag an element from startRef to endRef. Both refs must come from a recent browser_snapshot. Use for drag-and-drop interfaces, sliders, or reorderable lists.",
|
|
1053
|
+
{
|
|
1054
|
+
startRef: import_zod2.z.string().describe("Source element ref to drag from"),
|
|
1055
|
+
endRef: import_zod2.z.string().describe("Target element ref to drag to")
|
|
1056
|
+
},
|
|
1057
|
+
({ startRef, endRef }) => this.withLock(async () => {
|
|
1058
|
+
await requirePage().drag(startRef, endRef);
|
|
1059
|
+
return { content: [{ type: "text", text: `Dragged ref=${startRef} \u2192 ref=${endRef}` }] };
|
|
1060
|
+
})
|
|
1061
|
+
);
|
|
1062
|
+
server.tool(
|
|
1063
|
+
"browser_upload",
|
|
1064
|
+
"Upload local files to a file input element (<input type='file'>). The ref must point to a file input from browser_snapshot.",
|
|
1065
|
+
{
|
|
1066
|
+
ref: import_zod2.z.string().describe("Ref of the file input element from browser_snapshot"),
|
|
1067
|
+
paths: import_zod2.z.array(import_zod2.z.string()).describe("Absolute file path(s) on the local device to upload")
|
|
1068
|
+
},
|
|
1069
|
+
({ ref, paths }) => this.withLock(async () => {
|
|
1070
|
+
await requirePage().uploadFile(ref, paths);
|
|
1071
|
+
return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ref=${ref}` }] };
|
|
620
1072
|
})
|
|
621
1073
|
);
|
|
622
1074
|
server.tool(
|
|
623
1075
|
"browser_screenshot",
|
|
624
|
-
|
|
1076
|
+
[
|
|
1077
|
+
"Capture a screenshot of the current page. Returns base64 image data (viewable by AI) or saves to a file.",
|
|
1078
|
+
"",
|
|
1079
|
+
"Prefer browser_snapshot (Accessibility Tree) for understanding page structure \u2014 it's faster and machine-readable.",
|
|
1080
|
+
"Use browser_screenshot only when visual layout matters (charts, images, styling, visual verification)."
|
|
1081
|
+
].join("\n"),
|
|
625
1082
|
{
|
|
626
|
-
path: import_zod2.z.string().optional().describe("
|
|
627
|
-
|
|
1083
|
+
path: import_zod2.z.string().optional().describe("Save path for the screenshot. If omitted, returns base64 image data directly."),
|
|
1084
|
+
fullPage: import_zod2.z.boolean().optional().default(false).describe("Capture the full scrollable page, not just the visible viewport"),
|
|
1085
|
+
ref: import_zod2.z.string().optional().describe("Capture only a specific element by its ref from browser_snapshot")
|
|
628
1086
|
},
|
|
629
|
-
({ path: path4,
|
|
630
|
-
const
|
|
631
|
-
const screenshot = await page.screenshot({
|
|
632
|
-
path: path4 ?? void 0,
|
|
633
|
-
fullPage: full_page
|
|
634
|
-
});
|
|
1087
|
+
({ path: path4, fullPage, ref }) => this.withLock(async () => {
|
|
1088
|
+
const buffer = await requirePage().screenshot({ fullPage, ref });
|
|
635
1089
|
if (path4) {
|
|
636
|
-
|
|
1090
|
+
await import_promises2.default.writeFile(path4, buffer);
|
|
1091
|
+
return { content: [{ type: "text", text: `Screenshot saved: ${path4}` }] };
|
|
637
1092
|
}
|
|
638
1093
|
return {
|
|
639
|
-
content: [
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
]
|
|
1094
|
+
content: [{
|
|
1095
|
+
type: "image",
|
|
1096
|
+
data: buffer.toString("base64"),
|
|
1097
|
+
mimeType: "image/png"
|
|
1098
|
+
}]
|
|
646
1099
|
};
|
|
647
1100
|
})
|
|
648
1101
|
);
|
|
649
1102
|
server.tool(
|
|
650
|
-
"
|
|
651
|
-
"
|
|
652
|
-
{
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
]
|
|
660
|
-
};
|
|
1103
|
+
"browser_pdf",
|
|
1104
|
+
"Save the current page as a PDF file. Renders the full page including below-the-fold content. Useful for archiving, sharing, or offline reading.",
|
|
1105
|
+
{
|
|
1106
|
+
path: import_zod2.z.string().describe("Output file path (.pdf)")
|
|
1107
|
+
},
|
|
1108
|
+
({ path: path4 }) => this.withLock(async () => {
|
|
1109
|
+
const buffer = await requirePage().pdf();
|
|
1110
|
+
await import_promises2.default.writeFile(path4, buffer);
|
|
1111
|
+
return { content: [{ type: "text", text: `PDF saved: ${path4}` }] };
|
|
661
1112
|
})
|
|
662
1113
|
);
|
|
663
1114
|
server.tool(
|
|
664
1115
|
"browser_evaluate",
|
|
665
|
-
|
|
666
|
-
|
|
1116
|
+
[
|
|
1117
|
+
"Execute JavaScript code directly in the browser page context and return the result.",
|
|
1118
|
+
"",
|
|
1119
|
+
"Use for: extracting data not available in the Accessibility Tree, DOM manipulation, interacting with page APIs, or debugging.",
|
|
1120
|
+
"Wrap complex logic in an IIFE: (function(){ ... })()"
|
|
1121
|
+
].join("\n"),
|
|
1122
|
+
{
|
|
1123
|
+
code: import_zod2.z.string().describe("JavaScript code to execute in the page context. Return values are automatically serialized.")
|
|
1124
|
+
},
|
|
667
1125
|
({ code }) => this.withLock(async () => {
|
|
668
1126
|
try {
|
|
669
1127
|
const result = await requirePage().evaluate(code);
|
|
670
1128
|
return {
|
|
671
|
-
content: [
|
|
672
|
-
|
|
673
|
-
|
|
1129
|
+
content: [{
|
|
1130
|
+
type: "text",
|
|
1131
|
+
text: typeof result === "string" ? result : JSON.stringify(result, null, 2)
|
|
1132
|
+
}]
|
|
674
1133
|
};
|
|
675
1134
|
} catch (err) {
|
|
676
1135
|
return {
|
|
677
|
-
content: [{ type: "text", text: `\u274C
|
|
1136
|
+
content: [{ type: "text", text: `\u274C JS error: ${err.message}` }],
|
|
678
1137
|
isError: true
|
|
679
1138
|
};
|
|
680
1139
|
}
|
|
681
1140
|
})
|
|
682
1141
|
);
|
|
683
1142
|
server.tool(
|
|
684
|
-
"
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
1143
|
+
"browser_wait",
|
|
1144
|
+
[
|
|
1145
|
+
"Wait for a specific condition before proceeding. Use between actions when the page needs time to update.",
|
|
1146
|
+
"",
|
|
1147
|
+
"OPTIONS (use one): 'text' (wait for text to appear), 'textGone' (wait for text to disappear), 'url' (URL matches glob), 'loadState' (page load state), 'timeMs' (fixed delay as last resort)."
|
|
1148
|
+
].join("\n"),
|
|
1149
|
+
{
|
|
1150
|
+
text: import_zod2.z.string().optional().describe("Wait until this text appears on the page"),
|
|
1151
|
+
textGone: import_zod2.z.string().optional().describe("Wait until this text disappears from the page"),
|
|
1152
|
+
url: import_zod2.z.string().optional().describe("Wait until URL matches this glob pattern (e.g. '**/dashboard', '**/success')"),
|
|
1153
|
+
loadState: import_zod2.z.enum(["load", "domcontentloaded", "networkidle"]).optional().describe("Wait for page load state: 'load' (full), 'domcontentloaded' (DOM ready), 'networkidle' (no pending requests)"),
|
|
1154
|
+
timeMs: import_zod2.z.number().optional().describe("Fixed wait in milliseconds \u2014 use as last resort when other conditions don't apply")
|
|
1155
|
+
},
|
|
1156
|
+
({ text, textGone, url, loadState, timeMs }) => this.withLock(async () => {
|
|
1157
|
+
const condition = {};
|
|
1158
|
+
if (text) condition.text = text;
|
|
1159
|
+
if (textGone) condition.textGone = textGone;
|
|
1160
|
+
if (url) condition.url = url;
|
|
1161
|
+
if (loadState) condition.loadState = loadState;
|
|
1162
|
+
if (timeMs) condition.timeMs = timeMs;
|
|
1163
|
+
await requirePage().waitFor(condition);
|
|
1164
|
+
return { content: [{ type: "text", text: "Wait condition met" }] };
|
|
1165
|
+
})
|
|
1166
|
+
);
|
|
1167
|
+
server.tool(
|
|
1168
|
+
"browser_cookies",
|
|
1169
|
+
"Manage browser cookies: get all cookies, set a specific cookie, or clear all cookies. Useful for authentication state, session management, or testing.",
|
|
1170
|
+
{
|
|
1171
|
+
action: import_zod2.z.enum(["get", "set", "clear"]).describe("'get': retrieve all cookies, 'set': add/update a cookie, 'clear': remove all cookies"),
|
|
1172
|
+
cookie: import_zod2.z.object({
|
|
1173
|
+
name: import_zod2.z.string(),
|
|
1174
|
+
value: import_zod2.z.string(),
|
|
1175
|
+
domain: import_zod2.z.string().optional(),
|
|
1176
|
+
path: import_zod2.z.string().optional(),
|
|
1177
|
+
httpOnly: import_zod2.z.boolean().optional(),
|
|
1178
|
+
secure: import_zod2.z.boolean().optional()
|
|
1179
|
+
}).optional().describe("Cookie data (required for 'set' action)")
|
|
1180
|
+
},
|
|
1181
|
+
({ action, cookie }) => this.withLock(async () => {
|
|
1182
|
+
const page = requirePage();
|
|
1183
|
+
if (action === "get") {
|
|
1184
|
+
const cookies = await page.cookies();
|
|
1185
|
+
return { content: [{ type: "text", text: JSON.stringify(cookies, null, 2) }] };
|
|
1186
|
+
} else if (action === "set") {
|
|
1187
|
+
if (!cookie) throw new Error("cookie is required for set action");
|
|
1188
|
+
await page.setCookie({ path: "/", ...cookie });
|
|
1189
|
+
return { content: [{ type: "text", text: `Cookie set: ${cookie.name}` }] };
|
|
1190
|
+
} else {
|
|
1191
|
+
await page.clearCookies();
|
|
1192
|
+
return { content: [{ type: "text", text: "All cookies cleared" }] };
|
|
1193
|
+
}
|
|
1194
|
+
})
|
|
1195
|
+
);
|
|
1196
|
+
server.tool(
|
|
1197
|
+
"browser_storage",
|
|
1198
|
+
"Read, write, or clear browser localStorage/sessionStorage. Useful for managing client-side state, authentication tokens, or application preferences.",
|
|
1199
|
+
{
|
|
1200
|
+
action: import_zod2.z.enum(["get", "set", "clear"]).describe("'get': read value(s), 'set': write a key-value pair, 'clear': remove all entries"),
|
|
1201
|
+
kind: import_zod2.z.enum(["local", "session"]).optional().default("local").describe("'local' (persistent) or 'session' (cleared on tab close)"),
|
|
1202
|
+
key: import_zod2.z.string().optional().describe("Storage key to get or set. Omit key with 'get' to retrieve all entries."),
|
|
1203
|
+
value: import_zod2.z.string().optional().describe("Value to store (required for 'set' action)")
|
|
1204
|
+
},
|
|
1205
|
+
({ action, kind, key, value }) => this.withLock(async () => {
|
|
1206
|
+
const page = requirePage();
|
|
1207
|
+
if (action === "get") {
|
|
1208
|
+
const result = await page.storageGet(kind, key);
|
|
1209
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1210
|
+
} else if (action === "set") {
|
|
1211
|
+
if (!key || value === void 0) throw new Error("key and value are required for set action");
|
|
1212
|
+
await page.storageSet(kind, key, value);
|
|
1213
|
+
return { content: [{ type: "text", text: `Storage set: ${key}` }] };
|
|
1214
|
+
} else {
|
|
1215
|
+
await page.storageClear(kind);
|
|
1216
|
+
return { content: [{ type: "text", text: `${kind}Storage cleared` }] };
|
|
1217
|
+
}
|
|
1218
|
+
})
|
|
1219
|
+
);
|
|
1220
|
+
server.tool(
|
|
1221
|
+
"browser_dialog",
|
|
1222
|
+
[
|
|
1223
|
+
"Handle JavaScript dialogs (alert, confirm, prompt). Two-step pattern:",
|
|
1224
|
+
" 1. action='arm' \u2014 register a one-shot handler (returns immediately, does NOT block).",
|
|
1225
|
+
" 2. Trigger the dialog (e.g. browser_click on the button that calls confirm()).",
|
|
1226
|
+
" 3. action='wait' \u2014 await the handler to confirm the dialog was handled.",
|
|
1227
|
+
"",
|
|
1228
|
+
"The 'accept' and 'promptText' params are only used with action='arm'."
|
|
1229
|
+
].join("\n"),
|
|
1230
|
+
{
|
|
1231
|
+
action: import_zod2.z.enum(["arm", "wait"]).describe(
|
|
1232
|
+
"'arm' = register handler and return immediately; 'wait' = await the previously armed handler"
|
|
1233
|
+
),
|
|
1234
|
+
accept: import_zod2.z.boolean().optional().default(true).describe(
|
|
1235
|
+
"Accept (true) or dismiss (false) the dialog. Only used with action='arm'."
|
|
1236
|
+
),
|
|
1237
|
+
promptText: import_zod2.z.string().optional().describe(
|
|
1238
|
+
"Text to enter if the dialog is a prompt. Only used with action='arm'."
|
|
1239
|
+
),
|
|
1240
|
+
timeoutMs: import_zod2.z.number().optional().describe(
|
|
1241
|
+
"Timeout in ms for 'wait' action (default: 30000). Increase for slow-loading dialogs."
|
|
1242
|
+
)
|
|
1243
|
+
},
|
|
1244
|
+
({ action, accept, promptText, timeoutMs }) => this.withLock(async () => {
|
|
1245
|
+
if (action === "arm") {
|
|
1246
|
+
this.armedDialog = requirePage().armDialog({
|
|
1247
|
+
accept: accept ?? true,
|
|
1248
|
+
promptText,
|
|
1249
|
+
timeoutMs
|
|
1250
|
+
});
|
|
1251
|
+
this.armedDialog.catch(() => {
|
|
1252
|
+
});
|
|
1253
|
+
return { content: [{ type: "text", text: "Dialog handler armed. Trigger the dialog now, then call browser_dialog with action='wait'." }] };
|
|
1254
|
+
} else {
|
|
1255
|
+
if (!this.armedDialog) {
|
|
1256
|
+
return {
|
|
1257
|
+
content: [{ type: "text", text: "No dialog handler is armed. Call browser_dialog with action='arm' first." }],
|
|
1258
|
+
isError: true
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
const pending = this.armedDialog;
|
|
1262
|
+
this.armedDialog = null;
|
|
1263
|
+
await pending;
|
|
1264
|
+
return { content: [{ type: "text", text: "Dialog handled successfully." }] };
|
|
1265
|
+
}
|
|
690
1266
|
})
|
|
691
1267
|
);
|
|
692
1268
|
}
|
|
@@ -694,33 +1270,33 @@ var BrowserTools = class {
|
|
|
694
1270
|
|
|
695
1271
|
// src/tools/notebook.ts
|
|
696
1272
|
var import_zod3 = require("zod");
|
|
697
|
-
var
|
|
1273
|
+
var import_promises3 = __toESM(require("fs/promises"));
|
|
698
1274
|
var import_child_process2 = require("child_process");
|
|
699
1275
|
var import_util2 = require("util");
|
|
700
1276
|
var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
|
|
701
1277
|
async function readNotebook(filePath) {
|
|
702
|
-
const raw = await
|
|
1278
|
+
const raw = await import_promises3.default.readFile(filePath, "utf-8");
|
|
703
1279
|
try {
|
|
704
1280
|
return JSON.parse(raw);
|
|
705
1281
|
} catch {
|
|
706
|
-
throw new Error(
|
|
1282
|
+
throw new Error(`Invalid Jupyter notebook file: ${filePath}`);
|
|
707
1283
|
}
|
|
708
1284
|
}
|
|
709
1285
|
async function writeNotebook(filePath, nb) {
|
|
710
|
-
await
|
|
1286
|
+
await import_promises3.default.writeFile(filePath, JSON.stringify(nb, null, 1), "utf-8");
|
|
711
1287
|
}
|
|
712
1288
|
var NotebookTools = class {
|
|
713
1289
|
register(server) {
|
|
714
1290
|
server.tool(
|
|
715
1291
|
"notebook_read",
|
|
716
|
-
".ipynb
|
|
717
|
-
{ path: import_zod3.z.string().describe("
|
|
1292
|
+
"Read a Jupyter notebook (.ipynb) and return all cells with their types (code/markdown), source content, and output counts. Use this to understand notebook structure before making edits.",
|
|
1293
|
+
{ path: import_zod3.z.string().describe("Path to the .ipynb notebook file") },
|
|
718
1294
|
async ({ path: filePath }) => {
|
|
719
1295
|
const nb = await readNotebook(filePath);
|
|
720
1296
|
const cells = nb.cells.map((cell, i) => ({
|
|
721
1297
|
index: i,
|
|
722
1298
|
type: cell.cell_type,
|
|
723
|
-
source: cell.source.join(""),
|
|
1299
|
+
source: Array.isArray(cell.source) ? cell.source.join("") : cell.source,
|
|
724
1300
|
outputs: cell.outputs?.length ?? 0
|
|
725
1301
|
}));
|
|
726
1302
|
return {
|
|
@@ -730,30 +1306,35 @@ var NotebookTools = class {
|
|
|
730
1306
|
);
|
|
731
1307
|
server.tool(
|
|
732
1308
|
"notebook_edit_cell",
|
|
733
|
-
"
|
|
1309
|
+
"Replace the source code of a specific cell in a Jupyter notebook. Use notebook_read first to identify the correct cell index (0-based). Existing outputs for the cell are preserved \u2014 use notebook_execute to re-run.",
|
|
734
1310
|
{
|
|
735
|
-
path: import_zod3.z.string(),
|
|
736
|
-
cell_index: import_zod3.z.number().describe("0
|
|
737
|
-
source: import_zod3.z.string().describe("
|
|
1311
|
+
path: import_zod3.z.string().describe("Path to the .ipynb notebook file"),
|
|
1312
|
+
cell_index: import_zod3.z.number().describe("Cell index to edit (0-based). Use notebook_read to find the right index."),
|
|
1313
|
+
source: import_zod3.z.string().describe("New source code/content for the cell (replaces entire cell content)")
|
|
738
1314
|
},
|
|
739
1315
|
async ({ path: filePath, cell_index, source }) => {
|
|
740
1316
|
const nb = await readNotebook(filePath);
|
|
741
1317
|
if (cell_index < 0 || cell_index >= nb.cells.length) {
|
|
742
|
-
throw new Error(
|
|
1318
|
+
throw new Error(`Invalid cell index: ${cell_index}`);
|
|
743
1319
|
}
|
|
744
1320
|
nb.cells[cell_index].source = source.split("\n").map(
|
|
745
1321
|
(l, i, arr) => i < arr.length - 1 ? l + "\n" : l
|
|
746
1322
|
);
|
|
747
1323
|
await writeNotebook(filePath, nb);
|
|
748
|
-
return { content: [{ type: "text", text: "
|
|
1324
|
+
return { content: [{ type: "text", text: "Cell updated" }] };
|
|
749
1325
|
}
|
|
750
1326
|
);
|
|
751
1327
|
server.tool(
|
|
752
1328
|
"notebook_execute",
|
|
753
|
-
|
|
1329
|
+
[
|
|
1330
|
+
"Execute all cells in a Jupyter notebook using nbconvert. Results are saved in-place \u2014 the notebook file is updated with execution outputs.",
|
|
1331
|
+
"",
|
|
1332
|
+
"Requires Jupyter to be installed (pip install jupyter). The timeout applies per cell, not for the entire notebook.",
|
|
1333
|
+
"If execution fails on a cell, the error is captured in the cell output and subsequent cells may not execute."
|
|
1334
|
+
].join("\n"),
|
|
754
1335
|
{
|
|
755
|
-
path: import_zod3.z.string().describe("
|
|
756
|
-
timeout: import_zod3.z.number().optional().default(300).describe("
|
|
1336
|
+
path: import_zod3.z.string().describe("Path to the .ipynb notebook file to execute"),
|
|
1337
|
+
timeout: import_zod3.z.number().optional().default(300).describe("Maximum execution time per cell in seconds (default: 300). Increase for cells with heavy computation.")
|
|
757
1338
|
},
|
|
758
1339
|
async ({ path: filePath, timeout }) => {
|
|
759
1340
|
const nbconvertArgs = `nbconvert --to notebook --execute --inplace "${filePath}" --ExecutePreprocessor.timeout=${timeout}`;
|
|
@@ -769,7 +1350,7 @@ var NotebookTools = class {
|
|
|
769
1350
|
for (const jupyter of candidates) {
|
|
770
1351
|
try {
|
|
771
1352
|
const { stdout, stderr } = await execAsync2(`${jupyter} ${nbconvertArgs}`);
|
|
772
|
-
return { content: [{ type: "text", text: stdout || stderr || "
|
|
1353
|
+
return { content: [{ type: "text", text: stdout || stderr || "Execution complete" }] };
|
|
773
1354
|
} catch (err) {
|
|
774
1355
|
const error = err;
|
|
775
1356
|
if (error.code !== "127" && !error.message?.includes("not found") && !error.message?.includes("No such file")) {
|
|
@@ -777,17 +1358,17 @@ var NotebookTools = class {
|
|
|
777
1358
|
}
|
|
778
1359
|
}
|
|
779
1360
|
}
|
|
780
|
-
throw new Error("jupyter
|
|
1361
|
+
throw new Error("jupyter not found. Install it and try again: pip install jupyter");
|
|
781
1362
|
}
|
|
782
1363
|
);
|
|
783
1364
|
server.tool(
|
|
784
1365
|
"notebook_add_cell",
|
|
785
|
-
"
|
|
1366
|
+
"Insert a new cell into a Jupyter notebook. If position is omitted, the cell is appended at the end. Use cell_type='code' for executable Python cells, 'markdown' for documentation/text cells.",
|
|
786
1367
|
{
|
|
787
|
-
path: import_zod3.z.string().describe(".ipynb
|
|
788
|
-
cell_type: import_zod3.z.enum(["code", "markdown"]).describe("
|
|
789
|
-
source: import_zod3.z.string().describe("
|
|
790
|
-
position: import_zod3.z.number().optional().describe("
|
|
1368
|
+
path: import_zod3.z.string().describe("Path to the .ipynb notebook file"),
|
|
1369
|
+
cell_type: import_zod3.z.enum(["code", "markdown"]).describe("'code' for executable cells, 'markdown' for text/documentation cells"),
|
|
1370
|
+
source: import_zod3.z.string().describe("Cell source content (Python code or Markdown text)"),
|
|
1371
|
+
position: import_zod3.z.number().optional().describe("Insert position (0-based index). Omit to append at the end. If position exceeds cell count, appends at end with a warning.")
|
|
791
1372
|
},
|
|
792
1373
|
async ({ path: filePath, cell_type: cellType, source, position }) => {
|
|
793
1374
|
const nb = await readNotebook(filePath);
|
|
@@ -806,31 +1387,31 @@ var NotebookTools = class {
|
|
|
806
1387
|
} else if (position > nb.cells.length) {
|
|
807
1388
|
nb.cells.push(newCell);
|
|
808
1389
|
actualIndex = nb.cells.length - 1;
|
|
809
|
-
warning = ` (
|
|
1390
|
+
warning = ` (warning: position ${position} exceeded range, appended at end (index: ${actualIndex}))`;
|
|
810
1391
|
} else {
|
|
811
1392
|
const clamped = Math.max(0, position);
|
|
812
1393
|
nb.cells.splice(clamped, 0, newCell);
|
|
813
1394
|
actualIndex = clamped;
|
|
814
1395
|
}
|
|
815
1396
|
await writeNotebook(filePath, nb);
|
|
816
|
-
return { content: [{ type: "text", text:
|
|
1397
|
+
return { content: [{ type: "text", text: `Cell added (index: ${actualIndex})${warning}` }] };
|
|
817
1398
|
}
|
|
818
1399
|
);
|
|
819
1400
|
server.tool(
|
|
820
1401
|
"notebook_delete_cell",
|
|
821
|
-
"
|
|
1402
|
+
"Delete a cell from a Jupyter notebook by its 0-based index. Use notebook_read first to verify the cell content before deletion. This action cannot be undone.",
|
|
822
1403
|
{
|
|
823
|
-
path: import_zod3.z.string().describe(".ipynb
|
|
824
|
-
cell_index: import_zod3.z.number().describe("
|
|
1404
|
+
path: import_zod3.z.string().describe("Path to the .ipynb notebook file"),
|
|
1405
|
+
cell_index: import_zod3.z.number().describe("Cell index to delete (0-based). Use notebook_read first to verify content.")
|
|
825
1406
|
},
|
|
826
1407
|
async ({ path: filePath, cell_index }) => {
|
|
827
1408
|
const nb = await readNotebook(filePath);
|
|
828
1409
|
if (cell_index < 0 || cell_index >= nb.cells.length) {
|
|
829
|
-
throw new Error(
|
|
1410
|
+
throw new Error(`Invalid cell index: ${cell_index}`);
|
|
830
1411
|
}
|
|
831
1412
|
nb.cells.splice(cell_index, 1);
|
|
832
1413
|
await writeNotebook(filePath, nb);
|
|
833
|
-
return { content: [{ type: "text", text:
|
|
1414
|
+
return { content: [{ type: "text", text: `Cell deleted (index: ${cell_index})` }] };
|
|
834
1415
|
}
|
|
835
1416
|
);
|
|
836
1417
|
}
|
|
@@ -850,44 +1431,16 @@ function platform() {
|
|
|
850
1431
|
}
|
|
851
1432
|
var DeviceTools = class {
|
|
852
1433
|
register(server) {
|
|
853
|
-
server.tool(
|
|
854
|
-
"screen_capture",
|
|
855
|
-
"\uD654\uBA74 \uC2A4\uD06C\uB9B0\uC0F7 (OS \uB124\uC774\uD2F0\uBE0C)",
|
|
856
|
-
{
|
|
857
|
-
output_path: import_zod4.z.string().optional().describe("\uC800\uC7A5 \uACBD\uB85C (\uC5C6\uC73C\uBA74 temp \uC800\uC7A5 \uD6C4 base64 \uBC18\uD658)")
|
|
858
|
-
},
|
|
859
|
-
async ({ output_path }) => {
|
|
860
|
-
const p = platform();
|
|
861
|
-
const isTmp = !output_path;
|
|
862
|
-
const tmpPath = output_path ?? `/tmp/junis_screen_${Date.now()}.png`;
|
|
863
|
-
const cmd = {
|
|
864
|
-
mac: `screencapture -x "${tmpPath}"`,
|
|
865
|
-
win: `nircmd.exe savescreenshot "${tmpPath}"`,
|
|
866
|
-
linux: `scrot "${tmpPath}"`
|
|
867
|
-
}[p];
|
|
868
|
-
try {
|
|
869
|
-
await execAsync3(cmd);
|
|
870
|
-
} catch (err) {
|
|
871
|
-
throw new Error(`\uD654\uBA74 \uCEA1\uCC98 \uC2E4\uD328: ${err.message}`);
|
|
872
|
-
}
|
|
873
|
-
const { readFileSync, unlinkSync } = await import("fs");
|
|
874
|
-
const data = readFileSync(tmpPath).toString("base64");
|
|
875
|
-
if (isTmp) {
|
|
876
|
-
try {
|
|
877
|
-
unlinkSync(tmpPath);
|
|
878
|
-
} catch {
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
return {
|
|
882
|
-
content: [{ type: "image", data, mimeType: "image/png" }]
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
);
|
|
886
1434
|
server.tool(
|
|
887
1435
|
"camera_capture",
|
|
888
|
-
|
|
1436
|
+
[
|
|
1437
|
+
"Capture a photo from the device's camera and return it as base64 image data.",
|
|
1438
|
+
"",
|
|
1439
|
+
"Platform-specific: macOS (imagesnap), Windows (ffmpeg/dshow), Linux (fswebcam).",
|
|
1440
|
+
"Requires a connected camera with OS permissions granted. If output_path is provided, the file is also saved to disk."
|
|
1441
|
+
].join("\n"),
|
|
889
1442
|
{
|
|
890
|
-
output_path: import_zod4.z.string().optional()
|
|
1443
|
+
output_path: import_zod4.z.string().optional().describe("File path to save the captured photo. If omitted, returns image data only (temp file auto-cleaned).")
|
|
891
1444
|
},
|
|
892
1445
|
async ({ output_path }) => {
|
|
893
1446
|
const p = platform();
|
|
@@ -903,10 +1456,10 @@ var DeviceTools = class {
|
|
|
903
1456
|
} catch (err) {
|
|
904
1457
|
const e = err;
|
|
905
1458
|
return {
|
|
906
|
-
content: [{ type: "text", text: `\u274C
|
|
907
|
-
|
|
1459
|
+
content: [{ type: "text", text: `\u274C Camera not found or inaccessible.
|
|
1460
|
+
Cause: ${e.message}
|
|
908
1461
|
|
|
909
|
-
|
|
1462
|
+
Please check if a camera is connected.` }],
|
|
910
1463
|
isError: true
|
|
911
1464
|
};
|
|
912
1465
|
}
|
|
@@ -923,10 +1476,10 @@ var DeviceTools = class {
|
|
|
923
1476
|
);
|
|
924
1477
|
server.tool(
|
|
925
1478
|
"notification_send",
|
|
926
|
-
"OS
|
|
1479
|
+
"Send a native OS notification (banner/toast) to the user's desktop. Use for task completion alerts, reminders, or important status updates. The notification appears even when the terminal is not focused.",
|
|
927
1480
|
{
|
|
928
|
-
title: import_zod4.z.string().describe("
|
|
929
|
-
message: import_zod4.z.string().describe("
|
|
1481
|
+
title: import_zod4.z.string().describe("Notification title (displayed prominently)"),
|
|
1482
|
+
message: import_zod4.z.string().describe("Notification body text")
|
|
930
1483
|
},
|
|
931
1484
|
async ({ title, message }) => {
|
|
932
1485
|
try {
|
|
@@ -939,10 +1492,10 @@ var DeviceTools = class {
|
|
|
939
1492
|
}
|
|
940
1493
|
);
|
|
941
1494
|
});
|
|
942
|
-
return { content: [{ type: "text", text: "
|
|
1495
|
+
return { content: [{ type: "text", text: "Notification sent" }] };
|
|
943
1496
|
} catch (err) {
|
|
944
1497
|
return {
|
|
945
|
-
content: [{ type: "text", text:
|
|
1498
|
+
content: [{ type: "text", text: `Notification failed: ${err.message}` }],
|
|
946
1499
|
isError: true
|
|
947
1500
|
};
|
|
948
1501
|
}
|
|
@@ -950,7 +1503,7 @@ var DeviceTools = class {
|
|
|
950
1503
|
);
|
|
951
1504
|
server.tool(
|
|
952
1505
|
"clipboard_read",
|
|
953
|
-
"
|
|
1506
|
+
"Read the current contents of the system clipboard (text). Use to access content the user has copied. Platform-specific: macOS (pbpaste), Windows (PowerShell), Linux (xclip).",
|
|
954
1507
|
{},
|
|
955
1508
|
async () => {
|
|
956
1509
|
const p = platform();
|
|
@@ -961,8 +1514,10 @@ var DeviceTools = class {
|
|
|
961
1514
|
);
|
|
962
1515
|
server.tool(
|
|
963
1516
|
"clipboard_write",
|
|
964
|
-
"
|
|
965
|
-
{
|
|
1517
|
+
"Write text to the system clipboard, replacing its current contents. Use to prepare content for the user to paste elsewhere.",
|
|
1518
|
+
{
|
|
1519
|
+
text: import_zod4.z.string().describe("Text to copy to the clipboard")
|
|
1520
|
+
},
|
|
966
1521
|
async ({ text }) => {
|
|
967
1522
|
const p = platform();
|
|
968
1523
|
const cmd = {
|
|
@@ -971,21 +1526,26 @@ var DeviceTools = class {
|
|
|
971
1526
|
linux: `echo "${text}" | xclip -selection clipboard`
|
|
972
1527
|
}[p];
|
|
973
1528
|
await execAsync3(cmd);
|
|
974
|
-
return { content: [{ type: "text", text: "
|
|
1529
|
+
return { content: [{ type: "text", text: "Saved to clipboard" }] };
|
|
975
1530
|
}
|
|
976
1531
|
);
|
|
977
1532
|
server.tool(
|
|
978
1533
|
"screen_record",
|
|
979
|
-
|
|
1534
|
+
[
|
|
1535
|
+
"Start or stop screen recording. Captures the full screen as MP4 video.",
|
|
1536
|
+
"",
|
|
1537
|
+
"Use action='start' to begin, action='stop' to end and save. Only one recording can be active at a time.",
|
|
1538
|
+
"Platform-specific: macOS (screencapture -v), Windows/Linux (ffmpeg)."
|
|
1539
|
+
].join("\n"),
|
|
980
1540
|
{
|
|
981
|
-
action: import_zod4.z.enum(["start", "stop"]).describe("start:
|
|
982
|
-
output_path: import_zod4.z.string().optional().describe("
|
|
1541
|
+
action: import_zod4.z.enum(["start", "stop"]).describe("'start': begin recording, 'stop': end recording and save the file"),
|
|
1542
|
+
output_path: import_zod4.z.string().optional().describe("Output file path (used with 'start'). Default: /tmp/junis_record_<timestamp>.mp4")
|
|
983
1543
|
},
|
|
984
1544
|
async ({ action, output_path }) => {
|
|
985
1545
|
const p = platform();
|
|
986
1546
|
if (action === "start") {
|
|
987
1547
|
if (screenRecordPid) {
|
|
988
|
-
return { content: [{ type: "text", text: "
|
|
1548
|
+
return { content: [{ type: "text", text: "Already recording." }] };
|
|
989
1549
|
}
|
|
990
1550
|
const tmpPath = output_path ?? `/tmp/junis_record_${Date.now()}.mp4`;
|
|
991
1551
|
const { spawn: spawn2 } = await import("child_process");
|
|
@@ -993,10 +1553,10 @@ var DeviceTools = class {
|
|
|
993
1553
|
const child = spawn2(cmd[0], cmd[1], { detached: true, stdio: "ignore" });
|
|
994
1554
|
child.unref();
|
|
995
1555
|
screenRecordPid = child.pid ?? null;
|
|
996
|
-
return { content: [{ type: "text", text:
|
|
1556
|
+
return { content: [{ type: "text", text: `Recording started. Output path: ${tmpPath} (PID: ${screenRecordPid})` }] };
|
|
997
1557
|
} else {
|
|
998
1558
|
if (!screenRecordPid) {
|
|
999
|
-
return { content: [{ type: "text", text: "
|
|
1559
|
+
return { content: [{ type: "text", text: "Not currently recording." }] };
|
|
1000
1560
|
}
|
|
1001
1561
|
try {
|
|
1002
1562
|
process.kill(screenRecordPid, "SIGINT");
|
|
@@ -1004,13 +1564,18 @@ var DeviceTools = class {
|
|
|
1004
1564
|
} catch {
|
|
1005
1565
|
}
|
|
1006
1566
|
screenRecordPid = null;
|
|
1007
|
-
return { content: [{ type: "text", text: "
|
|
1567
|
+
return { content: [{ type: "text", text: "Recording stopped." }] };
|
|
1008
1568
|
}
|
|
1009
1569
|
}
|
|
1010
1570
|
);
|
|
1011
1571
|
server.tool(
|
|
1012
1572
|
"location_get",
|
|
1013
|
-
|
|
1573
|
+
[
|
|
1574
|
+
"Get the device's current geographic location.",
|
|
1575
|
+
"",
|
|
1576
|
+
"macOS: Uses CoreLocation (GPS-accurate) with IP-based fallback. Other platforms: IP-based geolocation (city-level accuracy only).",
|
|
1577
|
+
"Returns latitude, longitude, and (when available) city and country."
|
|
1578
|
+
].join("\n"),
|
|
1014
1579
|
{},
|
|
1015
1580
|
async () => {
|
|
1016
1581
|
const p = platform();
|
|
@@ -1018,28 +1583,28 @@ var DeviceTools = class {
|
|
|
1018
1583
|
try {
|
|
1019
1584
|
const { stdout } = await execAsync3("CoreLocationCLI -once -format '%latitude,%longitude'", { timeout: 1e4 });
|
|
1020
1585
|
const [lat, lon] = stdout.trim().split(",");
|
|
1021
|
-
return { content: [{ type: "text", text:
|
|
1586
|
+
return { content: [{ type: "text", text: `Latitude: ${lat}, Longitude: ${lon}` }] };
|
|
1022
1587
|
} catch {
|
|
1023
1588
|
}
|
|
1024
1589
|
}
|
|
1025
1590
|
const res = await fetch("http://ip-api.com/json/");
|
|
1026
1591
|
const data = await res.json();
|
|
1027
1592
|
if (data.status !== "success") {
|
|
1028
|
-
throw new Error(`IP
|
|
1593
|
+
throw new Error(`IP location lookup failed: ${data.message ?? data.status}`);
|
|
1029
1594
|
}
|
|
1030
1595
|
return {
|
|
1031
1596
|
content: [{
|
|
1032
1597
|
type: "text",
|
|
1033
|
-
text:
|
|
1598
|
+
text: `Latitude: ${data.lat}, Longitude: ${data.lon}, City: ${data.city}, Country: ${data.country} (estimated via IP)`
|
|
1034
1599
|
}]
|
|
1035
1600
|
};
|
|
1036
1601
|
}
|
|
1037
1602
|
);
|
|
1038
1603
|
server.tool(
|
|
1039
1604
|
"audio_play",
|
|
1040
|
-
"
|
|
1605
|
+
"Play an audio file through the device's speakers. Supports MP3, WAV, AAC, and other common formats. Playback is synchronous \u2014 the tool returns after playback completes. Platform-specific: macOS (afplay), Windows/Linux (ffplay).",
|
|
1041
1606
|
{
|
|
1042
|
-
file_path: import_zod4.z.string().describe("
|
|
1607
|
+
file_path: import_zod4.z.string().describe("Absolute path to the audio file to play")
|
|
1043
1608
|
},
|
|
1044
1609
|
async ({ file_path }) => {
|
|
1045
1610
|
const p = platform();
|
|
@@ -1049,7 +1614,302 @@ var DeviceTools = class {
|
|
|
1049
1614
|
linux: `ffplay -nodisp -autoexit "${file_path}"`
|
|
1050
1615
|
}[p];
|
|
1051
1616
|
await execAsync3(cmd);
|
|
1052
|
-
return { content: [{ type: "text", text:
|
|
1617
|
+
return { content: [{ type: "text", text: `Playback complete: ${file_path}` }] };
|
|
1618
|
+
}
|
|
1619
|
+
);
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1623
|
+
// src/setup/peekaboo-installer.ts
|
|
1624
|
+
var import_child_process4 = require("child_process");
|
|
1625
|
+
var import_util4 = require("util");
|
|
1626
|
+
var import_os2 = require("os");
|
|
1627
|
+
var execFileAsync2 = (0, import_util4.promisify)(import_child_process4.execFile);
|
|
1628
|
+
async function ensurePeekaboo() {
|
|
1629
|
+
if ((0, import_os2.platform)() !== "darwin") return false;
|
|
1630
|
+
try {
|
|
1631
|
+
await execFileAsync2("which", ["peekaboo"]);
|
|
1632
|
+
return true;
|
|
1633
|
+
} catch {
|
|
1634
|
+
console.log("\u23F3 peekaboo not found, installing via brew...");
|
|
1635
|
+
try {
|
|
1636
|
+
await execFileAsync2("brew", ["tap", "steipete/tap"], { timeout: 3e4 });
|
|
1637
|
+
await execFileAsync2("brew", ["install", "peekaboo"], { timeout: 12e4 });
|
|
1638
|
+
console.log("\u2705 peekaboo installed");
|
|
1639
|
+
return true;
|
|
1640
|
+
} catch (brewErr) {
|
|
1641
|
+
console.warn("\u26A0\uFE0F peekaboo install failed:", brewErr.message);
|
|
1642
|
+
console.warn(" Desktop tools disabled. Install manually: brew tap steipete/tap && brew install peekaboo");
|
|
1643
|
+
return false;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// src/tools/desktop.ts
|
|
1649
|
+
var import_execa = require("execa");
|
|
1650
|
+
var import_zod5 = require("zod");
|
|
1651
|
+
var import_fs2 = __toESM(require("fs"));
|
|
1652
|
+
var APP_BLACKLIST = /* @__PURE__ */ new Set([
|
|
1653
|
+
"Terminal",
|
|
1654
|
+
"iTerm2",
|
|
1655
|
+
"iTerm",
|
|
1656
|
+
"Finder"
|
|
1657
|
+
// 파일 삭제 위험
|
|
1658
|
+
]);
|
|
1659
|
+
var consecutiveFailures = 0;
|
|
1660
|
+
var MAX_CONSECUTIVE_FAILURES = 2;
|
|
1661
|
+
async function peekaboo(args) {
|
|
1662
|
+
consecutiveFailures = 0;
|
|
1663
|
+
try {
|
|
1664
|
+
const { stdout } = await (0, import_execa.execa)("peekaboo", [...args, "--json-output"]);
|
|
1665
|
+
consecutiveFailures = 0;
|
|
1666
|
+
return JSON.parse(stdout);
|
|
1667
|
+
} catch (err) {
|
|
1668
|
+
consecutiveFailures++;
|
|
1669
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
1670
|
+
consecutiveFailures = 0;
|
|
1671
|
+
throw new Error(`peekaboo failed ${MAX_CONSECUTIVE_FAILURES} times in a row. Auto-stopped for safety. Last error: ${err.message}`);
|
|
1672
|
+
}
|
|
1673
|
+
throw err;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
function checkBlacklist(app) {
|
|
1677
|
+
if (app && APP_BLACKLIST.has(app)) {
|
|
1678
|
+
throw new Error(`App '${app}' is not allowed for automation (blacklisted for safety).`);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
var DesktopTools = class {
|
|
1682
|
+
register(server) {
|
|
1683
|
+
server.tool(
|
|
1684
|
+
"desktop_see",
|
|
1685
|
+
[
|
|
1686
|
+
"Capture the macOS Accessibility Tree snapshot for a running application. Returns structured element list with IDs, roles, labels, and positions.",
|
|
1687
|
+
"",
|
|
1688
|
+
"WORKFLOW: Call desktop_see \u2192 find target element \u2192 use its ID in desktop_click or desktop_type.",
|
|
1689
|
+
"Pass the returned snapshotId to subsequent calls for 240x speed improvement (cached lookup vs. full re-scan).",
|
|
1690
|
+
"",
|
|
1691
|
+
"SAFETY: Terminal, iTerm, and Finder are blocked. Two consecutive failures trigger an automatic safety stop."
|
|
1692
|
+
].join("\n"),
|
|
1693
|
+
{
|
|
1694
|
+
app: import_zod5.z.string().optional().describe("App name to target (e.g. 'Safari', 'Notes', 'Google Chrome'). Omit for the frontmost app.")
|
|
1695
|
+
},
|
|
1696
|
+
async ({ app }) => {
|
|
1697
|
+
checkBlacklist(app);
|
|
1698
|
+
const args = ["see"];
|
|
1699
|
+
if (app) args.push("--app", app);
|
|
1700
|
+
const result = await peekaboo(args);
|
|
1701
|
+
const data = result.data;
|
|
1702
|
+
const snapshotId = data?.snapshot_id ?? result.snapshotId ?? result.snapshot_id;
|
|
1703
|
+
const elements = (data?.ui_elements ?? data?.elements ?? result.elements)?.map((e) => ({
|
|
1704
|
+
id: e.id,
|
|
1705
|
+
role: e.role,
|
|
1706
|
+
label: e.label,
|
|
1707
|
+
bounds: e.bounds
|
|
1708
|
+
})) ?? [];
|
|
1709
|
+
return {
|
|
1710
|
+
content: [{
|
|
1711
|
+
type: "text",
|
|
1712
|
+
text: JSON.stringify({ snapshotId, elements }, null, 2)
|
|
1713
|
+
}]
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
);
|
|
1717
|
+
server.tool(
|
|
1718
|
+
"desktop_click",
|
|
1719
|
+
[
|
|
1720
|
+
"Click a macOS UI element by its accessibility label, ID, or x,y coordinates.",
|
|
1721
|
+
"",
|
|
1722
|
+
"The 'on' parameter accepts: element label text (e.g. 'Save'), accessibility ID from desktop_see, or coordinates as 'x,y' string.",
|
|
1723
|
+
"For faster interaction, pass the snapshotId from a recent desktop_see call.",
|
|
1724
|
+
"",
|
|
1725
|
+
"SAFETY: Terminal, iTerm, and Finder are blocked. Two consecutive failures trigger automatic safety stop."
|
|
1726
|
+
].join("\n"),
|
|
1727
|
+
{
|
|
1728
|
+
on: import_zod5.z.string().describe("Element label, accessibility ID, or 'x,y' coordinates to click"),
|
|
1729
|
+
app: import_zod5.z.string().optional().describe("App name to target (e.g. 'Safari')"),
|
|
1730
|
+
snapshot: import_zod5.z.string().optional().describe("snapshotId from desktop_see for cached interaction (240x faster)"),
|
|
1731
|
+
doubleClick: import_zod5.z.boolean().optional().default(false).describe("Double-click instead of single click")
|
|
1732
|
+
},
|
|
1733
|
+
async ({ on, app, snapshot, doubleClick }) => {
|
|
1734
|
+
checkBlacklist(app);
|
|
1735
|
+
const args = ["click", "--on", on];
|
|
1736
|
+
if (app) args.push("--app", app);
|
|
1737
|
+
if (snapshot) args.push("--snapshot", snapshot);
|
|
1738
|
+
if (doubleClick) args.push("--double-click");
|
|
1739
|
+
const result = await peekaboo(args);
|
|
1740
|
+
return {
|
|
1741
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
);
|
|
1745
|
+
server.tool(
|
|
1746
|
+
"desktop_type",
|
|
1747
|
+
[
|
|
1748
|
+
"Type text into the currently focused UI element on macOS. The text is sent as keyboard input character-by-character.",
|
|
1749
|
+
"",
|
|
1750
|
+
"SAFETY: Terminal, iTerm, and Finder are blocked. Use desktop_see first to verify the correct element is focused."
|
|
1751
|
+
].join("\n"),
|
|
1752
|
+
{
|
|
1753
|
+
text: import_zod5.z.string().describe("Text to type into the focused element"),
|
|
1754
|
+
app: import_zod5.z.string().optional().describe("App name to focus before typing")
|
|
1755
|
+
},
|
|
1756
|
+
async ({ text, app }) => {
|
|
1757
|
+
checkBlacklist(app);
|
|
1758
|
+
const args = ["type", text];
|
|
1759
|
+
if (app) args.push("--app", app);
|
|
1760
|
+
const result = await peekaboo(args);
|
|
1761
|
+
return {
|
|
1762
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
);
|
|
1766
|
+
server.tool(
|
|
1767
|
+
"desktop_hotkey",
|
|
1768
|
+
[
|
|
1769
|
+
"Press a keyboard shortcut on macOS. Keys are comma-separated.",
|
|
1770
|
+
"",
|
|
1771
|
+
"Common shortcuts: 'cmd,c' (copy), 'cmd,v' (paste), 'cmd,z' (undo), 'cmd,s' (save), 'cmd,w' (close tab), 'cmd,q' (quit), 'cmd,shift,t' (reopen tab), 'cmd,tab' (switch app).",
|
|
1772
|
+
"",
|
|
1773
|
+
"SAFETY: Terminal, iTerm, and Finder are blocked."
|
|
1774
|
+
].join("\n"),
|
|
1775
|
+
{
|
|
1776
|
+
keys: import_zod5.z.string().describe("Comma-separated key combination (e.g. 'cmd,c', 'cmd,shift,t', 'escape', 'cmd,option,i')"),
|
|
1777
|
+
app: import_zod5.z.string().optional().describe("App name to target")
|
|
1778
|
+
},
|
|
1779
|
+
async ({ keys, app }) => {
|
|
1780
|
+
checkBlacklist(app);
|
|
1781
|
+
const args = ["hotkey", keys];
|
|
1782
|
+
if (app) args.push("--app", app);
|
|
1783
|
+
const result = await peekaboo(args);
|
|
1784
|
+
return {
|
|
1785
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
);
|
|
1789
|
+
server.tool(
|
|
1790
|
+
"desktop_scroll",
|
|
1791
|
+
"Scroll within a macOS application or specific UI element. Use 'ticks' to control scroll distance (default: 3). Can target a specific element by label or ID with the 'on' parameter.",
|
|
1792
|
+
{
|
|
1793
|
+
direction: import_zod5.z.enum(["up", "down", "left", "right"]).describe("Scroll direction"),
|
|
1794
|
+
ticks: import_zod5.z.number().optional().default(3).describe("Number of scroll ticks (default: 3). Higher = more scrolling."),
|
|
1795
|
+
on: import_zod5.z.string().optional().describe("Element label or ID to scroll within (from desktop_see). Omit to scroll the active area."),
|
|
1796
|
+
app: import_zod5.z.string().optional().describe("App name to target")
|
|
1797
|
+
},
|
|
1798
|
+
async ({ direction, ticks, on, app }) => {
|
|
1799
|
+
checkBlacklist(app);
|
|
1800
|
+
const args = ["scroll", "--direction", direction, "--amount", String(ticks)];
|
|
1801
|
+
if (on) args.push("--on", on);
|
|
1802
|
+
if (app) args.push("--app", app);
|
|
1803
|
+
const result = await peekaboo(args);
|
|
1804
|
+
return {
|
|
1805
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
);
|
|
1809
|
+
server.tool(
|
|
1810
|
+
"desktop_list_apps",
|
|
1811
|
+
"List all currently running applications on macOS. Returns app names that can be used as the 'app' parameter in other desktop tools (desktop_see, desktop_click, desktop_type, etc.).",
|
|
1812
|
+
{},
|
|
1813
|
+
async () => {
|
|
1814
|
+
try {
|
|
1815
|
+
const { stdout } = await (0, import_execa.execa)("peekaboo", ["list", "apps", "--json"]);
|
|
1816
|
+
return {
|
|
1817
|
+
content: [{ type: "text", text: stdout }]
|
|
1818
|
+
};
|
|
1819
|
+
} catch (err) {
|
|
1820
|
+
consecutiveFailures++;
|
|
1821
|
+
throw err;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
);
|
|
1825
|
+
server.tool(
|
|
1826
|
+
"desktop_list_windows",
|
|
1827
|
+
"List all open windows on macOS, optionally filtered by app name. If no app is specified, lists windows for the frontmost application. Useful for identifying which windows are available for automation.",
|
|
1828
|
+
{
|
|
1829
|
+
app: import_zod5.z.string().optional().describe("Filter by app name. Omit to query the frontmost app.")
|
|
1830
|
+
},
|
|
1831
|
+
async ({ app }) => {
|
|
1832
|
+
checkBlacklist(app);
|
|
1833
|
+
try {
|
|
1834
|
+
let targetApp = app;
|
|
1835
|
+
if (!targetApp) {
|
|
1836
|
+
const { stdout: stdout2 } = await (0, import_execa.execa)("osascript", [
|
|
1837
|
+
"-e",
|
|
1838
|
+
'tell application "System Events" to get name of first application process whose frontmost is true'
|
|
1839
|
+
]);
|
|
1840
|
+
targetApp = stdout2.trim();
|
|
1841
|
+
}
|
|
1842
|
+
const args = ["list", "windows", "--app", targetApp, "--json"];
|
|
1843
|
+
const { stdout } = await (0, import_execa.execa)("peekaboo", args);
|
|
1844
|
+
return {
|
|
1845
|
+
content: [{ type: "text", text: stdout }]
|
|
1846
|
+
};
|
|
1847
|
+
} catch (err) {
|
|
1848
|
+
consecutiveFailures++;
|
|
1849
|
+
throw err;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
);
|
|
1853
|
+
server.tool(
|
|
1854
|
+
"desktop_screenshot",
|
|
1855
|
+
[
|
|
1856
|
+
"Take a high-quality macOS screenshot using Peekaboo (Retina display support). Returns base64 image data.",
|
|
1857
|
+
"",
|
|
1858
|
+
"MODES: 'screen' captures the full display, 'window' captures a specific app window.",
|
|
1859
|
+
"Prefer desktop_see (Accessibility Tree) for understanding UI structure \u2014 use screenshot only when visual appearance matters (layouts, images, colors)."
|
|
1860
|
+
].join("\n"),
|
|
1861
|
+
{
|
|
1862
|
+
app: import_zod5.z.string().optional().describe("Capture a specific app's window (by name)"),
|
|
1863
|
+
mode: import_zod5.z.enum(["screen", "window"]).optional().default("screen").describe("'screen': full display capture, 'window': specific app window only")
|
|
1864
|
+
},
|
|
1865
|
+
async ({ app, mode }) => {
|
|
1866
|
+
checkBlacklist(app);
|
|
1867
|
+
const args = ["image", "--mode", mode];
|
|
1868
|
+
if (app) args.push("--app", app);
|
|
1869
|
+
const result = await peekaboo(args);
|
|
1870
|
+
const data = result.data;
|
|
1871
|
+
const files = data?.files;
|
|
1872
|
+
const filePath = files?.[0]?.path;
|
|
1873
|
+
if (filePath) {
|
|
1874
|
+
const imageBuffer = import_fs2.default.readFileSync(filePath);
|
|
1875
|
+
return {
|
|
1876
|
+
content: [{
|
|
1877
|
+
type: "image",
|
|
1878
|
+
data: imageBuffer.toString("base64"),
|
|
1879
|
+
mimeType: "image/png"
|
|
1880
|
+
}]
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
return {
|
|
1884
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
);
|
|
1888
|
+
server.tool(
|
|
1889
|
+
"desktop_menu",
|
|
1890
|
+
[
|
|
1891
|
+
"Click a menu bar item in a macOS application. Navigate nested menus by adding path segments.",
|
|
1892
|
+
"",
|
|
1893
|
+
"Examples: ['File', 'New Tab'], ['Edit', 'Find', 'Find...'], ['View', 'Enter Full Screen'].",
|
|
1894
|
+
"The target app must be running and accessible."
|
|
1895
|
+
].join("\n"),
|
|
1896
|
+
{
|
|
1897
|
+
path: import_zod5.z.array(import_zod5.z.string()).describe("Menu path as array (e.g. ['File', 'Save'], ['Edit', 'Find', 'Find...'])"),
|
|
1898
|
+
app: import_zod5.z.string().optional().describe("App name to target. Omit for the frontmost app.")
|
|
1899
|
+
},
|
|
1900
|
+
async ({ path: path4, app }) => {
|
|
1901
|
+
checkBlacklist(app);
|
|
1902
|
+
const args = ["menu", "click", "--path", path4.join(" > ")];
|
|
1903
|
+
if (app) args.push("--app", app);
|
|
1904
|
+
try {
|
|
1905
|
+
const { stdout } = await (0, import_execa.execa)("peekaboo", args);
|
|
1906
|
+
return {
|
|
1907
|
+
content: [{ type: "text", text: stdout || "Menu click executed" }]
|
|
1908
|
+
};
|
|
1909
|
+
} catch (err) {
|
|
1910
|
+
consecutiveFailures++;
|
|
1911
|
+
throw err;
|
|
1912
|
+
}
|
|
1053
1913
|
}
|
|
1054
1914
|
);
|
|
1055
1915
|
}
|
|
@@ -1058,6 +1918,7 @@ var DeviceTools = class {
|
|
|
1058
1918
|
// src/server/mcp.ts
|
|
1059
1919
|
var mcpPort = 3e3;
|
|
1060
1920
|
var globalBrowserTools = null;
|
|
1921
|
+
var desktopToolsEnabled = false;
|
|
1061
1922
|
function createMcpServer() {
|
|
1062
1923
|
const server = new import_mcp.McpServer({
|
|
1063
1924
|
name: "junis",
|
|
@@ -1072,6 +1933,10 @@ function createMcpServer() {
|
|
|
1072
1933
|
notebookTools.register(server);
|
|
1073
1934
|
const deviceTools = new DeviceTools();
|
|
1074
1935
|
deviceTools.register(server);
|
|
1936
|
+
if (desktopToolsEnabled) {
|
|
1937
|
+
const desktopTools = new DesktopTools();
|
|
1938
|
+
desktopTools.register(server);
|
|
1939
|
+
}
|
|
1075
1940
|
return server;
|
|
1076
1941
|
}
|
|
1077
1942
|
function readBody(req) {
|
|
@@ -1180,6 +2045,10 @@ function handleOAuthDiscovery(req, res, port) {
|
|
|
1180
2045
|
async function startMCPServer(port) {
|
|
1181
2046
|
globalBrowserTools = new BrowserTools();
|
|
1182
2047
|
await globalBrowserTools.init();
|
|
2048
|
+
desktopToolsEnabled = await ensurePeekaboo();
|
|
2049
|
+
if (desktopToolsEnabled) {
|
|
2050
|
+
console.log("\u2705 Peekaboo available \u2014 desktop tools enabled");
|
|
2051
|
+
}
|
|
1183
2052
|
let resolvedPort = port;
|
|
1184
2053
|
const httpServer = (0, import_http.createServer)(
|
|
1185
2054
|
async (req, res) => {
|
|
@@ -1290,62 +2159,129 @@ async function handleMCPRequest(id, payload) {
|
|
|
1290
2159
|
if (!res.ok) {
|
|
1291
2160
|
throw new Error(`MCP request failed: ${res.status} ${res.statusText}`);
|
|
1292
2161
|
}
|
|
2162
|
+
if (res.status === 202) {
|
|
2163
|
+
return null;
|
|
2164
|
+
}
|
|
2165
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
2166
|
+
if (contentType.includes("application/json")) {
|
|
2167
|
+
return res.json();
|
|
2168
|
+
}
|
|
1293
2169
|
const text = await res.text();
|
|
1294
2170
|
const lines = text.split("\n");
|
|
2171
|
+
let currentEventType = null;
|
|
2172
|
+
const collectedResults = [];
|
|
2173
|
+
let lastError = null;
|
|
1295
2174
|
for (const line of lines) {
|
|
1296
|
-
if (line.startsWith("
|
|
2175
|
+
if (line.startsWith("event: ")) {
|
|
2176
|
+
currentEventType = line.slice(7).trim();
|
|
2177
|
+
} else if (line.startsWith("data: ")) {
|
|
2178
|
+
const rawData = line.slice(6).trim();
|
|
2179
|
+
if (rawData === "") {
|
|
2180
|
+
currentEventType = null;
|
|
2181
|
+
continue;
|
|
2182
|
+
}
|
|
1297
2183
|
try {
|
|
1298
|
-
|
|
2184
|
+
const parsed = JSON.parse(rawData);
|
|
2185
|
+
if (currentEventType === "error") {
|
|
2186
|
+
lastError = parsed;
|
|
2187
|
+
} else if (currentEventType === "message" || currentEventType === null) {
|
|
2188
|
+
collectedResults.push(parsed);
|
|
2189
|
+
}
|
|
1299
2190
|
} catch {
|
|
1300
2191
|
}
|
|
2192
|
+
currentEventType = null;
|
|
2193
|
+
} else if (line === "") {
|
|
2194
|
+
currentEventType = null;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
if (collectedResults.length === 0 && lastError !== null) {
|
|
2198
|
+
throw new Error(
|
|
2199
|
+
`MCP error event: ${JSON.stringify(lastError)}`
|
|
2200
|
+
);
|
|
2201
|
+
}
|
|
2202
|
+
if (collectedResults.length > 1 && payload !== null && typeof payload === "object" && "id" in payload) {
|
|
2203
|
+
const requestId = payload.id;
|
|
2204
|
+
const matched = collectedResults.find(
|
|
2205
|
+
(r) => r !== null && typeof r === "object" && "id" in r && r.id === requestId
|
|
2206
|
+
);
|
|
2207
|
+
if (matched !== void 0) {
|
|
2208
|
+
return matched;
|
|
1301
2209
|
}
|
|
1302
2210
|
}
|
|
2211
|
+
if (collectedResults.length > 0) {
|
|
2212
|
+
return collectedResults[collectedResults.length - 1];
|
|
2213
|
+
}
|
|
1303
2214
|
return null;
|
|
1304
2215
|
}
|
|
1305
2216
|
|
|
2217
|
+
// src/server/stdio.ts
|
|
2218
|
+
var import_mcp2 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
2219
|
+
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
2220
|
+
async function startStdioServer() {
|
|
2221
|
+
const server = new import_mcp2.McpServer({ name: "junis", version: "0.1.0" });
|
|
2222
|
+
const fsTools = new FilesystemTools();
|
|
2223
|
+
fsTools.register(server);
|
|
2224
|
+
const browserTools = new BrowserTools();
|
|
2225
|
+
await browserTools.init();
|
|
2226
|
+
browserTools.register(server);
|
|
2227
|
+
const notebookTools = new NotebookTools();
|
|
2228
|
+
notebookTools.register(server);
|
|
2229
|
+
const deviceTools = new DeviceTools();
|
|
2230
|
+
deviceTools.register(server);
|
|
2231
|
+
const transport = new import_stdio.StdioServerTransport();
|
|
2232
|
+
await server.connect(transport);
|
|
2233
|
+
process.on("SIGINT", async () => {
|
|
2234
|
+
await browserTools.cleanup();
|
|
2235
|
+
process.exit(0);
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
if (require.main === module) {
|
|
2239
|
+
startStdioServer().catch(console.error);
|
|
2240
|
+
}
|
|
2241
|
+
|
|
1306
2242
|
// src/cli/daemon.ts
|
|
1307
|
-
var
|
|
2243
|
+
var import_fs3 = __toESM(require("fs"));
|
|
1308
2244
|
var import_path3 = __toESM(require("path"));
|
|
1309
|
-
var
|
|
1310
|
-
var
|
|
1311
|
-
var CONFIG_DIR2 = import_path3.default.join(
|
|
2245
|
+
var import_os3 = __toESM(require("os"));
|
|
2246
|
+
var import_child_process5 = require("child_process");
|
|
2247
|
+
var CONFIG_DIR2 = import_path3.default.join(import_os3.default.homedir(), ".junis");
|
|
1312
2248
|
var PID_FILE = import_path3.default.join(CONFIG_DIR2, "junis.pid");
|
|
1313
2249
|
var LOG_DIR = import_path3.default.join(CONFIG_DIR2, "logs");
|
|
1314
2250
|
var LOG_FILE = import_path3.default.join(LOG_DIR, "junis.log");
|
|
1315
2251
|
var PLIST_PATH = import_path3.default.join(
|
|
1316
|
-
|
|
2252
|
+
import_os3.default.homedir(),
|
|
1317
2253
|
"Library/LaunchAgents/ai.junis.plist"
|
|
1318
2254
|
);
|
|
1319
2255
|
var SYSTEMD_PATH = import_path3.default.join(
|
|
1320
|
-
|
|
2256
|
+
import_os3.default.homedir(),
|
|
1321
2257
|
".config/systemd/user/junis.service"
|
|
1322
2258
|
);
|
|
1323
2259
|
function isRunning() {
|
|
1324
2260
|
try {
|
|
1325
|
-
if (!
|
|
1326
|
-
const pid = parseInt(
|
|
2261
|
+
if (!import_fs3.default.existsSync(PID_FILE)) return { running: false };
|
|
2262
|
+
const pid = parseInt(import_fs3.default.readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
1327
2263
|
if (isNaN(pid)) return { running: false };
|
|
1328
2264
|
process.kill(pid, 0);
|
|
1329
2265
|
return { running: true, pid };
|
|
1330
2266
|
} catch {
|
|
1331
2267
|
try {
|
|
1332
|
-
|
|
2268
|
+
import_fs3.default.unlinkSync(PID_FILE);
|
|
1333
2269
|
} catch {
|
|
1334
2270
|
}
|
|
1335
2271
|
return { running: false };
|
|
1336
2272
|
}
|
|
1337
2273
|
}
|
|
1338
2274
|
function writePid(pid) {
|
|
1339
|
-
|
|
1340
|
-
|
|
2275
|
+
import_fs3.default.mkdirSync(CONFIG_DIR2, { recursive: true });
|
|
2276
|
+
import_fs3.default.writeFileSync(PID_FILE, String(pid), "utf-8");
|
|
1341
2277
|
}
|
|
1342
2278
|
function startDaemon(port) {
|
|
1343
|
-
|
|
2279
|
+
import_fs3.default.mkdirSync(LOG_DIR, { recursive: true });
|
|
1344
2280
|
const nodePath = process.execPath;
|
|
1345
2281
|
const scriptPath = process.argv[1];
|
|
1346
|
-
const out =
|
|
1347
|
-
const err =
|
|
1348
|
-
const child = (0,
|
|
2282
|
+
const out = import_fs3.default.openSync(LOG_FILE, "a");
|
|
2283
|
+
const err = import_fs3.default.openSync(LOG_FILE, "a");
|
|
2284
|
+
const child = (0, import_child_process5.spawn)(nodePath, [scriptPath, "start", "--daemon", "--port", String(port)], {
|
|
1349
2285
|
detached: true,
|
|
1350
2286
|
stdio: ["ignore", out, err],
|
|
1351
2287
|
env: { ...process.env }
|
|
@@ -1361,7 +2297,7 @@ function stopDaemon() {
|
|
|
1361
2297
|
try {
|
|
1362
2298
|
process.kill(pid, "SIGTERM");
|
|
1363
2299
|
try {
|
|
1364
|
-
|
|
2300
|
+
import_fs3.default.unlinkSync(PID_FILE);
|
|
1365
2301
|
} catch {
|
|
1366
2302
|
}
|
|
1367
2303
|
return true;
|
|
@@ -1395,7 +2331,7 @@ var ServiceManager = class {
|
|
|
1395
2331
|
<key>EnvironmentVariables</key>
|
|
1396
2332
|
<dict>
|
|
1397
2333
|
<key>HOME</key>
|
|
1398
|
-
<string>${
|
|
2334
|
+
<string>${import_os3.default.homedir()}</string>
|
|
1399
2335
|
<key>PATH</key>
|
|
1400
2336
|
<string>${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}</string>
|
|
1401
2337
|
${process.env.JUNIS_API_URL ? `<key>JUNIS_API_URL</key>
|
|
@@ -1415,14 +2351,14 @@ var ServiceManager = class {
|
|
|
1415
2351
|
<string>${LOG_FILE}</string>
|
|
1416
2352
|
</dict>
|
|
1417
2353
|
</plist>`;
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
2354
|
+
import_fs3.default.mkdirSync(import_path3.default.dirname(PLIST_PATH), { recursive: true });
|
|
2355
|
+
import_fs3.default.mkdirSync(LOG_DIR, { recursive: true });
|
|
2356
|
+
import_fs3.default.writeFileSync(PLIST_PATH, plist, "utf-8");
|
|
1421
2357
|
try {
|
|
1422
|
-
(0,
|
|
1423
|
-
(0,
|
|
2358
|
+
(0, import_child_process5.execSync)(`launchctl unload "${PLIST_PATH}" 2>/dev/null || true`);
|
|
2359
|
+
(0, import_child_process5.execSync)(`launchctl load "${PLIST_PATH}"`);
|
|
1424
2360
|
} catch (e) {
|
|
1425
|
-
throw new Error(`launchctl load
|
|
2361
|
+
throw new Error(`launchctl load failed: ${e.message}`);
|
|
1426
2362
|
}
|
|
1427
2363
|
} else if (this.platform === "linux") {
|
|
1428
2364
|
const unit = `[Unit]
|
|
@@ -1433,7 +2369,7 @@ After=network.target
|
|
|
1433
2369
|
ExecStart=${nodePath} ${scriptPath} start --daemon
|
|
1434
2370
|
Restart=always
|
|
1435
2371
|
RestartSec=5
|
|
1436
|
-
Environment=HOME=${
|
|
2372
|
+
Environment=HOME=${import_os3.default.homedir()}
|
|
1437
2373
|
Environment=PATH=${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}
|
|
1438
2374
|
${process.env.JUNIS_API_URL ? `Environment=JUNIS_API_URL=${process.env.JUNIS_API_URL}` : ""}
|
|
1439
2375
|
${process.env.JUNIS_WS_URL ? `Environment=JUNIS_WS_URL=${process.env.JUNIS_WS_URL}` : ""}
|
|
@@ -1443,14 +2379,14 @@ StandardError=append:${LOG_FILE}
|
|
|
1443
2379
|
|
|
1444
2380
|
[Install]
|
|
1445
2381
|
WantedBy=default.target`;
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
(0,
|
|
1450
|
-
(0,
|
|
1451
|
-
(0,
|
|
2382
|
+
import_fs3.default.mkdirSync(import_path3.default.dirname(SYSTEMD_PATH), { recursive: true });
|
|
2383
|
+
import_fs3.default.mkdirSync(LOG_DIR, { recursive: true });
|
|
2384
|
+
import_fs3.default.writeFileSync(SYSTEMD_PATH, unit, "utf-8");
|
|
2385
|
+
(0, import_child_process5.execSync)("systemctl --user daemon-reload");
|
|
2386
|
+
(0, import_child_process5.execSync)("systemctl --user enable junis");
|
|
2387
|
+
(0, import_child_process5.execSync)("systemctl --user start junis");
|
|
1452
2388
|
} else {
|
|
1453
|
-
(0,
|
|
2389
|
+
(0, import_child_process5.execSync)(
|
|
1454
2390
|
`schtasks /Create /F /TN "Junis" /TR "${nodePath} ${scriptPath} start --daemon" /SC ONLOGON /RL HIGHEST`
|
|
1455
2391
|
);
|
|
1456
2392
|
}
|
|
@@ -1458,21 +2394,21 @@ WantedBy=default.target`;
|
|
|
1458
2394
|
async uninstall() {
|
|
1459
2395
|
if (this.platform === "mac") {
|
|
1460
2396
|
try {
|
|
1461
|
-
(0,
|
|
1462
|
-
if (
|
|
2397
|
+
(0, import_child_process5.execSync)(`launchctl unload "${PLIST_PATH}" 2>/dev/null || true`);
|
|
2398
|
+
if (import_fs3.default.existsSync(PLIST_PATH)) import_fs3.default.unlinkSync(PLIST_PATH);
|
|
1463
2399
|
} catch {
|
|
1464
2400
|
}
|
|
1465
2401
|
} else if (this.platform === "linux") {
|
|
1466
2402
|
try {
|
|
1467
|
-
(0,
|
|
1468
|
-
(0,
|
|
1469
|
-
if (
|
|
1470
|
-
(0,
|
|
2403
|
+
(0, import_child_process5.execSync)("systemctl --user stop junis 2>/dev/null || true");
|
|
2404
|
+
(0, import_child_process5.execSync)("systemctl --user disable junis 2>/dev/null || true");
|
|
2405
|
+
if (import_fs3.default.existsSync(SYSTEMD_PATH)) import_fs3.default.unlinkSync(SYSTEMD_PATH);
|
|
2406
|
+
(0, import_child_process5.execSync)("systemctl --user daemon-reload 2>/dev/null || true");
|
|
1471
2407
|
} catch {
|
|
1472
2408
|
}
|
|
1473
2409
|
} else {
|
|
1474
2410
|
try {
|
|
1475
|
-
(0,
|
|
2411
|
+
(0, import_child_process5.execSync)('schtasks /Delete /F /TN "Junis" 2>nul || true');
|
|
1476
2412
|
} catch {
|
|
1477
2413
|
}
|
|
1478
2414
|
}
|
|
@@ -1481,10 +2417,10 @@ WantedBy=default.target`;
|
|
|
1481
2417
|
|
|
1482
2418
|
// src/cli/index.ts
|
|
1483
2419
|
var { version } = require_package();
|
|
1484
|
-
import_commander.program.name("junis").description("
|
|
2420
|
+
import_commander.program.name("junis").description("MCP server for full device control by AI").version(version);
|
|
1485
2421
|
function getSystemInfo() {
|
|
1486
|
-
const
|
|
1487
|
-
if (
|
|
2422
|
+
const platform3 = process.platform;
|
|
2423
|
+
if (platform3 === "darwin") {
|
|
1488
2424
|
try {
|
|
1489
2425
|
const { execSync: execSync2 } = require("child_process");
|
|
1490
2426
|
const sw = execSync2("sw_vers -productVersion", { encoding: "utf8" }).trim();
|
|
@@ -1494,13 +2430,13 @@ function getSystemInfo() {
|
|
|
1494
2430
|
return "macOS";
|
|
1495
2431
|
}
|
|
1496
2432
|
}
|
|
1497
|
-
if (
|
|
2433
|
+
if (platform3 === "win32") return "Windows";
|
|
1498
2434
|
return "Linux";
|
|
1499
2435
|
}
|
|
1500
2436
|
function getDeviceName() {
|
|
1501
|
-
const
|
|
1502
|
-
if (
|
|
1503
|
-
if (
|
|
2437
|
+
const platform3 = process.platform;
|
|
2438
|
+
if (platform3 === "darwin") return "Mac";
|
|
2439
|
+
if (platform3 === "win32") return "Windows PC";
|
|
1504
2440
|
return "Linux PC";
|
|
1505
2441
|
}
|
|
1506
2442
|
function printBanner() {
|
|
@@ -1539,24 +2475,27 @@ async function runForeground(config, port) {
|
|
|
1539
2475
|
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1540
2476
|
console.log(` \u25C9 MCP server started on port ${actualPort}`);
|
|
1541
2477
|
const relay = new RelayClient(config, handleMCPRequest, async () => {
|
|
1542
|
-
console.log("[junis]
|
|
2478
|
+
console.log("[junis] Session expired - re-authentication required");
|
|
1543
2479
|
try {
|
|
1544
2480
|
let waitingPrinted = false;
|
|
1545
2481
|
const authResult = await authenticate(
|
|
1546
2482
|
deviceName,
|
|
1547
2483
|
platformName,
|
|
1548
2484
|
(uri) => {
|
|
1549
|
-
console.log(`[junis]
|
|
2485
|
+
console.log(`[junis] Browser re-auth: ${uri}`);
|
|
1550
2486
|
},
|
|
1551
2487
|
() => {
|
|
1552
2488
|
if (!waitingPrinted) waitingPrinted = true;
|
|
1553
|
-
}
|
|
2489
|
+
},
|
|
2490
|
+
config.device_key,
|
|
2491
|
+
config.token,
|
|
2492
|
+
config.device_key
|
|
1554
2493
|
);
|
|
1555
2494
|
config.token = authResult.token;
|
|
1556
2495
|
saveConfig(config);
|
|
1557
2496
|
relay.restart();
|
|
1558
2497
|
} catch (e) {
|
|
1559
|
-
console.error("[junis]
|
|
2498
|
+
console.error("[junis] Re-authentication failed:", e);
|
|
1560
2499
|
process.exit(1);
|
|
1561
2500
|
}
|
|
1562
2501
|
});
|
|
@@ -1565,8 +2504,8 @@ async function runForeground(config, port) {
|
|
|
1565
2504
|
console.log(" \u25C9 Relay connected");
|
|
1566
2505
|
console.log("");
|
|
1567
2506
|
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1568
|
-
console.log(" \u2705 ALL SET \u2014 Junis
|
|
1569
|
-
console.log(" Ctrl+C
|
|
2507
|
+
console.log(" \u2705 ALL SET \u2014 Junis is running in the foreground.");
|
|
2508
|
+
console.log(" Press Ctrl+C to stop.");
|
|
1570
2509
|
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1571
2510
|
console.log("");
|
|
1572
2511
|
console.log(` \u2192 ${webUrl}`);
|
|
@@ -1585,33 +2524,45 @@ async function runBackground(config, port) {
|
|
|
1585
2524
|
console.log(" STEP 5 \xB7 Starting Background Service");
|
|
1586
2525
|
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1587
2526
|
const svc = new ServiceManager();
|
|
2527
|
+
let serviceInstalled = false;
|
|
1588
2528
|
try {
|
|
1589
2529
|
await svc.install();
|
|
2530
|
+
serviceInstalled = true;
|
|
1590
2531
|
console.log(" \u25C9 Service registered ........... \u2705");
|
|
1591
2532
|
console.log(" \u25C9 Auto-start on boot ........... \u2705");
|
|
1592
2533
|
} catch (e) {
|
|
1593
|
-
console.warn(` \u26A0\uFE0F
|
|
1594
|
-
console.warn("
|
|
2534
|
+
console.warn(` \u26A0\uFE0F Service registration failed: ${e.message}`);
|
|
2535
|
+
console.warn(" Running as background process only.");
|
|
1595
2536
|
startDaemon(port);
|
|
1596
2537
|
}
|
|
1597
2538
|
const webUrl = process.env.JUNIS_WEB_URL ?? "https://junis.ai";
|
|
1598
2539
|
console.log("");
|
|
1599
2540
|
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1600
|
-
console.log(" \u2705 ALL SET \u2014 Junis
|
|
1601
|
-
|
|
2541
|
+
console.log(" \u2705 ALL SET \u2014 Junis is running in the background.");
|
|
2542
|
+
if (serviceInstalled) {
|
|
2543
|
+
console.log(" Auto-starts on boot.");
|
|
2544
|
+
} else {
|
|
2545
|
+
console.log(" \u26A0\uFE0F Auto-start on boot is NOT enabled (service registration failed).");
|
|
2546
|
+
console.log(" Run 'npx junis stop' and try 'npx junis' again to retry.");
|
|
2547
|
+
}
|
|
1602
2548
|
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1603
2549
|
console.log("");
|
|
1604
2550
|
console.log(` \u2192 ${webUrl}`);
|
|
1605
2551
|
console.log("");
|
|
1606
|
-
console.log("
|
|
2552
|
+
console.log(" To stop: npx junis stop");
|
|
1607
2553
|
console.log("");
|
|
1608
2554
|
process.exit(0);
|
|
1609
2555
|
}
|
|
1610
|
-
import_commander.program.command("start", { isDefault: true }).description("Junis
|
|
2556
|
+
import_commander.program.command("start", { isDefault: true }).description("Start Junis agent connection").option("--local", "Run local MCP server only (no cloud connection)").option("--port <number>", "Port number", "3000").option("--reset", "Clear existing credentials and re-login").option("--daemon", "Run in daemon mode (internal, used by launchd/systemd)").option("--foreground", "Run in foreground mode (no prompt)").option("--stdio", "Run as stdio transport (for MCP client integration)").action(async (options) => {
|
|
1611
2557
|
const port = parseInt(options.port, 10);
|
|
2558
|
+
if (options.stdio) {
|
|
2559
|
+
await startStdioServer();
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
1612
2562
|
if (options.foreground) {
|
|
1613
2563
|
printBanner();
|
|
1614
|
-
|
|
2564
|
+
const existingConfig2 = loadConfig();
|
|
2565
|
+
let config2 = options.reset ? null : existingConfig2;
|
|
1615
2566
|
const deviceName2 = config2?.device_name ?? `${process.env["USER"] ?? "user"}'s ${getDeviceName()}`;
|
|
1616
2567
|
const platformName2 = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux";
|
|
1617
2568
|
printStep1(port);
|
|
@@ -1634,7 +2585,10 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1634
2585
|
} else {
|
|
1635
2586
|
process.stdout.write("\xB7");
|
|
1636
2587
|
}
|
|
1637
|
-
}
|
|
2588
|
+
},
|
|
2589
|
+
existingConfig2?.device_key,
|
|
2590
|
+
existingConfig2?.token,
|
|
2591
|
+
existingConfig2?.device_key
|
|
1638
2592
|
);
|
|
1639
2593
|
console.log("");
|
|
1640
2594
|
console.log(` \u2705 Authenticated as ${authResult.email ?? "your account"}`);
|
|
@@ -1684,9 +2638,10 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1684
2638
|
await startMCPServer(port);
|
|
1685
2639
|
return;
|
|
1686
2640
|
}
|
|
1687
|
-
|
|
2641
|
+
const existingConfig2 = loadConfig();
|
|
2642
|
+
let config2 = options.reset ? null : existingConfig2;
|
|
1688
2643
|
if (!config2) {
|
|
1689
|
-
console.error("\u274C
|
|
2644
|
+
console.error("\u274C No credentials found. Run npx junis first.");
|
|
1690
2645
|
process.exit(1);
|
|
1691
2646
|
}
|
|
1692
2647
|
const deviceName2 = config2.device_name;
|
|
@@ -1694,24 +2649,27 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1694
2649
|
const actualPort = await startMCPServer(port);
|
|
1695
2650
|
console.log(`[junis daemon] MCP server started on port ${actualPort}`);
|
|
1696
2651
|
const relay = new RelayClient(config2, handleMCPRequest, async () => {
|
|
1697
|
-
console.log("[junis daemon]
|
|
2652
|
+
console.log("[junis daemon] Session expired - re-authentication required");
|
|
1698
2653
|
try {
|
|
1699
2654
|
let waitingPrinted = false;
|
|
1700
2655
|
const authResult = await authenticate(
|
|
1701
2656
|
deviceName2,
|
|
1702
2657
|
platformName2,
|
|
1703
2658
|
(uri) => {
|
|
1704
|
-
console.log(`[junis daemon]
|
|
2659
|
+
console.log(`[junis daemon] Browser re-auth: ${uri}`);
|
|
1705
2660
|
},
|
|
1706
2661
|
() => {
|
|
1707
2662
|
if (!waitingPrinted) waitingPrinted = true;
|
|
1708
|
-
}
|
|
2663
|
+
},
|
|
2664
|
+
config2.device_key,
|
|
2665
|
+
config2.token,
|
|
2666
|
+
config2.device_key
|
|
1709
2667
|
);
|
|
1710
2668
|
config2.token = authResult.token;
|
|
1711
2669
|
saveConfig(config2);
|
|
1712
2670
|
relay.restart();
|
|
1713
2671
|
} catch (e) {
|
|
1714
|
-
console.error("[junis daemon]
|
|
2672
|
+
console.error("[junis daemon] Re-authentication failed:", e);
|
|
1715
2673
|
process.exit(1);
|
|
1716
2674
|
}
|
|
1717
2675
|
});
|
|
@@ -1731,11 +2689,27 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1731
2689
|
printBanner();
|
|
1732
2690
|
const { running, pid } = isRunning();
|
|
1733
2691
|
if (running) {
|
|
1734
|
-
console.log(`\u2705 Junis
|
|
1735
|
-
console.log("
|
|
2692
|
+
console.log(`\u2705 Junis is running. (PID: ${pid})`);
|
|
2693
|
+
console.log(" To stop: npx junis stop");
|
|
1736
2694
|
return;
|
|
1737
2695
|
}
|
|
1738
|
-
|
|
2696
|
+
const mode = await (0, import_prompts.select)({
|
|
2697
|
+
message: "Select run mode:",
|
|
2698
|
+
choices: [
|
|
2699
|
+
{
|
|
2700
|
+
name: "Foreground",
|
|
2701
|
+
value: "foreground",
|
|
2702
|
+
description: "Runs in the current terminal. Press Ctrl+C to stop.\n Full OS access: camera, notifications, and more."
|
|
2703
|
+
},
|
|
2704
|
+
{
|
|
2705
|
+
name: "Background (daemon)",
|
|
2706
|
+
value: "background",
|
|
2707
|
+
description: "Runs as a background service. Stays alive after\n closing the terminal. Auto-starts on reboot."
|
|
2708
|
+
}
|
|
2709
|
+
]
|
|
2710
|
+
});
|
|
2711
|
+
const existingConfig = loadConfig();
|
|
2712
|
+
let config = options.reset ? null : existingConfig;
|
|
1739
2713
|
const deviceName = config?.device_name ?? `${process.env["USER"] ?? "user"}'s ${getDeviceName()}`;
|
|
1740
2714
|
const platformName = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux";
|
|
1741
2715
|
printStep1(port);
|
|
@@ -1758,7 +2732,10 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1758
2732
|
} else {
|
|
1759
2733
|
process.stdout.write("\xB7");
|
|
1760
2734
|
}
|
|
1761
|
-
}
|
|
2735
|
+
},
|
|
2736
|
+
existingConfig?.device_key,
|
|
2737
|
+
existingConfig?.token,
|
|
2738
|
+
existingConfig?.device_key
|
|
1762
2739
|
);
|
|
1763
2740
|
console.log("");
|
|
1764
2741
|
console.log(` \u2705 Authenticated as ${authResult.email ?? "your account"}`);
|
|
@@ -1799,28 +2776,13 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1799
2776
|
console.log(" \u25C9 Status ....................... \u{1F7E2} online");
|
|
1800
2777
|
console.log("");
|
|
1801
2778
|
}
|
|
1802
|
-
const mode = await (0, import_prompts.select)({
|
|
1803
|
-
message: "Select run mode:",
|
|
1804
|
-
choices: [
|
|
1805
|
-
{
|
|
1806
|
-
name: "Foreground",
|
|
1807
|
-
value: "foreground",
|
|
1808
|
-
description: "Runs in the current terminal. Press Ctrl+C to stop.\n Full OS access: camera, notifications, and more."
|
|
1809
|
-
},
|
|
1810
|
-
{
|
|
1811
|
-
name: "Background (daemon)",
|
|
1812
|
-
value: "background",
|
|
1813
|
-
description: "Runs as a background service. Stays alive after\n closing the terminal. Auto-starts on reboot."
|
|
1814
|
-
}
|
|
1815
|
-
]
|
|
1816
|
-
});
|
|
1817
2779
|
if (mode === "foreground") {
|
|
1818
2780
|
await runForeground(config, port);
|
|
1819
2781
|
} else {
|
|
1820
2782
|
await runBackground(config, port);
|
|
1821
2783
|
}
|
|
1822
2784
|
});
|
|
1823
|
-
import_commander.program.command("stop").description("
|
|
2785
|
+
import_commander.program.command("stop").description("Stop background service and disable auto-start").action(async () => {
|
|
1824
2786
|
const stopped = stopDaemon();
|
|
1825
2787
|
const svc = new ServiceManager();
|
|
1826
2788
|
let serviceUninstalled = false;
|
|
@@ -1830,29 +2792,47 @@ import_commander.program.command("stop").description("\uBC31\uADF8\uB77C\uC6B4\u
|
|
|
1830
2792
|
} catch {
|
|
1831
2793
|
}
|
|
1832
2794
|
if (stopped || serviceUninstalled) {
|
|
1833
|
-
console.log("\u2705 Junis
|
|
1834
|
-
console.log("
|
|
2795
|
+
console.log("\u2705 Junis service has been stopped.");
|
|
2796
|
+
console.log(" Auto-start has been disabled.");
|
|
1835
2797
|
} else {
|
|
1836
|
-
console.log("\u2139\uFE0F
|
|
2798
|
+
console.log("\u2139\uFE0F No running Junis process found.");
|
|
1837
2799
|
}
|
|
1838
2800
|
});
|
|
1839
|
-
import_commander.program.command("logout").description("
|
|
2801
|
+
import_commander.program.command("logout").description("Clear authentication credentials").action(() => {
|
|
1840
2802
|
clearConfig();
|
|
1841
|
-
console.log("\u2705
|
|
2803
|
+
console.log("\u2705 Authentication credentials cleared");
|
|
1842
2804
|
});
|
|
1843
|
-
import_commander.program.command("status").description("
|
|
2805
|
+
import_commander.program.command("status").description("Check current status").action(() => {
|
|
1844
2806
|
const config = loadConfig();
|
|
1845
2807
|
const { running, pid } = isRunning();
|
|
1846
2808
|
if (!config) {
|
|
1847
|
-
console.log("\u274C
|
|
2809
|
+
console.log("\u274C Not authenticated (run npx junis)");
|
|
1848
2810
|
} else if (running) {
|
|
1849
|
-
console.log(`\u2705
|
|
1850
|
-
console.log(`
|
|
1851
|
-
console.log(`
|
|
2811
|
+
console.log(`\u2705 Running (PID: ${pid})`);
|
|
2812
|
+
console.log(` Device: ${config.device_name}`);
|
|
2813
|
+
console.log(` Registered: ${config.created_at}`);
|
|
1852
2814
|
} else {
|
|
1853
|
-
console.log("\u26A0\uFE0F
|
|
1854
|
-
console.log(`
|
|
1855
|
-
console.log("
|
|
2815
|
+
console.log("\u26A0\uFE0F Authenticated, service stopped");
|
|
2816
|
+
console.log(` Device: ${config.device_name}`);
|
|
2817
|
+
console.log(" To start: npx junis");
|
|
1856
2818
|
}
|
|
1857
2819
|
});
|
|
2820
|
+
import_commander.program.addHelpText("after", `
|
|
2821
|
+
Examples:
|
|
2822
|
+
npx junis Interactive mode (foreground/background)
|
|
2823
|
+
npx junis --local Local MCP server only (no cloud)
|
|
2824
|
+
npx junis --stdio Run as stdio MCP server for Claude Code, etc.
|
|
2825
|
+
npx junis stop Stop background service
|
|
2826
|
+
npx junis status Check current status
|
|
2827
|
+
|
|
2828
|
+
MCP Client Config (Claude Code, Claude Desktop, Codex, etc.):
|
|
2829
|
+
{
|
|
2830
|
+
"mcpServers": {
|
|
2831
|
+
"junis": {
|
|
2832
|
+
"command": "npx",
|
|
2833
|
+
"args": ["-y", "junis", "--stdio"]
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
`);
|
|
1858
2838
|
import_commander.program.parse();
|