junis 0.2.6 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -4
- package/dist/cli/index.js +1103 -319
- package/dist/server/mcp.js +919 -204
- package/dist/server/stdio.js +661 -204
- 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.
|
|
34
|
+
version: "0.3.1",
|
|
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
|
},
|
|
@@ -96,9 +98,9 @@ function saveConfig(config) {
|
|
|
96
98
|
import_fs.default.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
97
99
|
} catch (err) {
|
|
98
100
|
console.error(`
|
|
99
|
-
\u274C
|
|
100
|
-
console.error(`
|
|
101
|
-
console.error(`
|
|
101
|
+
\u274C Failed to save config file: ${err.message}`);
|
|
102
|
+
console.error(` Please save it manually to ${CONFIG_FILE}.`);
|
|
103
|
+
console.error(` Content: ${JSON.stringify(config, null, 2)}`);
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
function clearConfig() {
|
|
@@ -123,23 +125,32 @@ var JUNIS_WEB = (() => {
|
|
|
123
125
|
}
|
|
124
126
|
return null;
|
|
125
127
|
})();
|
|
126
|
-
async function authenticate(deviceName,
|
|
128
|
+
async function authenticate(deviceName, platform3, onBrowserOpen, onWaiting, existingDeviceKey, existingToken, oldDeviceKey) {
|
|
129
|
+
const headers = { "Content-Type": "application/json" };
|
|
130
|
+
if (existingToken) {
|
|
131
|
+
headers["Authorization"] = `Bearer ${existingToken}`;
|
|
132
|
+
}
|
|
127
133
|
let startRes;
|
|
128
134
|
try {
|
|
129
135
|
startRes = await fetch(`${JUNIS_API}/api/auth/device/start`, {
|
|
130
136
|
method: "POST",
|
|
131
|
-
headers
|
|
132
|
-
body: JSON.stringify({
|
|
137
|
+
headers,
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
device_name: deviceName,
|
|
140
|
+
platform: platform3,
|
|
141
|
+
...existingDeviceKey && { device_key: existingDeviceKey },
|
|
142
|
+
...oldDeviceKey ? { old_device_key: oldDeviceKey } : {}
|
|
143
|
+
})
|
|
133
144
|
});
|
|
134
145
|
} catch (err) {
|
|
135
146
|
throw new Error(
|
|
136
|
-
|
|
147
|
+
`Cannot connect to server. Please check your internet connection or try again later.
|
|
137
148
|
(${err.message})`
|
|
138
149
|
);
|
|
139
150
|
}
|
|
140
151
|
if (!startRes.ok) {
|
|
141
152
|
const body = await startRes.text().catch(() => "");
|
|
142
|
-
throw new Error(`Auth
|
|
153
|
+
throw new Error(`Auth start failed: ${startRes.status} ${body}`);
|
|
143
154
|
}
|
|
144
155
|
const startData = await startRes.json();
|
|
145
156
|
const verificationUri = JUNIS_WEB ? startData.verification_uri.replace(/^https?:\/\/[^/]+/, JUNIS_WEB) : startData.verification_uri;
|
|
@@ -148,7 +159,7 @@ async function authenticate(deviceName, platform2, onBrowserOpen, onWaiting) {
|
|
|
148
159
|
await (0, import_open.default)(verificationUri);
|
|
149
160
|
} catch {
|
|
150
161
|
console.warn(`
|
|
151
|
-
\u26A0\uFE0F
|
|
162
|
+
\u26A0\uFE0F Could not open browser automatically. Please open the following URL manually:
|
|
152
163
|
|
|
153
164
|
${verificationUri}
|
|
154
165
|
`);
|
|
@@ -181,11 +192,11 @@ async function authenticate(deviceName, platform2, onBrowserOpen, onWaiting) {
|
|
|
181
192
|
return result;
|
|
182
193
|
}
|
|
183
194
|
if (pollRes.status === 410) {
|
|
184
|
-
throw new Error("
|
|
195
|
+
throw new Error("Authentication code has expired. Please try again.");
|
|
185
196
|
}
|
|
186
|
-
throw new Error(
|
|
197
|
+
throw new Error(`Unexpected response: ${pollRes.status}`);
|
|
187
198
|
}
|
|
188
|
-
throw new Error("
|
|
199
|
+
throw new Error("Authentication timed out (5 min). Please try again.");
|
|
189
200
|
}
|
|
190
201
|
function sleep(ms) {
|
|
191
202
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -216,14 +227,14 @@ var RelayClient = class {
|
|
|
216
227
|
async connect() {
|
|
217
228
|
if (this.destroyed) return;
|
|
218
229
|
const url = `${JUNIS_WS}/ws/devices/${this.config.device_key}`;
|
|
219
|
-
console.log(`\u{1F517}
|
|
230
|
+
console.log(`\u{1F517} Connecting to relay server...`);
|
|
220
231
|
const ws = new import_ws.default(url, {
|
|
221
232
|
headers: { Authorization: `Bearer ${this.config.token}` }
|
|
222
233
|
});
|
|
223
234
|
this.ws = ws;
|
|
224
235
|
ws.on("open", () => {
|
|
225
236
|
if (this.ws !== ws) return;
|
|
226
|
-
console.log("\u2705
|
|
237
|
+
console.log("\u2705 Connected to relay server");
|
|
227
238
|
this.reconnectDelay = 1e3;
|
|
228
239
|
this.startHeartbeat();
|
|
229
240
|
});
|
|
@@ -257,18 +268,18 @@ var RelayClient = class {
|
|
|
257
268
|
await this.onAuthExpired();
|
|
258
269
|
} else {
|
|
259
270
|
console.error(
|
|
260
|
-
"\n\u274C
|
|
271
|
+
"\n\u274C Auth token expired. Run `npx junis --reset` to re-authenticate."
|
|
261
272
|
);
|
|
262
273
|
process.exit(1);
|
|
263
274
|
}
|
|
264
275
|
return;
|
|
265
276
|
}
|
|
266
|
-
console.log(`\u26A0\uFE0F
|
|
277
|
+
console.log(`\u26A0\uFE0F Disconnected. Reconnecting in ${this.reconnectDelay / 1e3}s...`);
|
|
267
278
|
setTimeout(() => this.connect(), this.reconnectDelay);
|
|
268
279
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 3e4);
|
|
269
280
|
});
|
|
270
281
|
ws.on("error", (err) => {
|
|
271
|
-
console.error(
|
|
282
|
+
console.error(`Relay error: ${err.message}`);
|
|
272
283
|
});
|
|
273
284
|
}
|
|
274
285
|
/** 재인증 완료 후 재연결 */
|
|
@@ -312,29 +323,81 @@ var import_promises = __toESM(require("fs/promises"));
|
|
|
312
323
|
var import_path2 = __toESM(require("path"));
|
|
313
324
|
var import_glob = require("glob");
|
|
314
325
|
var import_zod = require("zod");
|
|
326
|
+
|
|
327
|
+
// src/server/permissions.ts
|
|
328
|
+
var toolPermissions = {
|
|
329
|
+
// 읽기 전용 — 자동 허용
|
|
330
|
+
browser_snapshot: "auto",
|
|
331
|
+
browser_screenshot: "auto",
|
|
332
|
+
desktop_see: "auto",
|
|
333
|
+
desktop_list_apps: "auto",
|
|
334
|
+
desktop_list_windows: "auto",
|
|
335
|
+
cron_list: "auto",
|
|
336
|
+
read_file: "auto",
|
|
337
|
+
list_directory: "auto",
|
|
338
|
+
list_processes: "auto",
|
|
339
|
+
search_code: "auto",
|
|
340
|
+
// 상호작용 — 확인 권장 (현재: auto와 동일하게 실행, 향후 UI 연동)
|
|
341
|
+
browser_click: "confirm",
|
|
342
|
+
browser_type: "confirm",
|
|
343
|
+
browser_navigate: "confirm",
|
|
344
|
+
browser_fill: "confirm",
|
|
345
|
+
browser_select: "confirm",
|
|
346
|
+
browser_press: "confirm",
|
|
347
|
+
browser_hover: "confirm",
|
|
348
|
+
browser_drag: "confirm",
|
|
349
|
+
browser_upload: "confirm",
|
|
350
|
+
browser_cookies: "confirm",
|
|
351
|
+
browser_storage: "confirm",
|
|
352
|
+
browser_dialog: "confirm",
|
|
353
|
+
desktop_click: "confirm",
|
|
354
|
+
desktop_type: "confirm",
|
|
355
|
+
desktop_hotkey: "confirm",
|
|
356
|
+
desktop_scroll: "confirm",
|
|
357
|
+
desktop_menu: "confirm",
|
|
358
|
+
desktop_screenshot: "confirm",
|
|
359
|
+
cron_create: "confirm",
|
|
360
|
+
cron_delete: "confirm",
|
|
361
|
+
edit_block: "confirm",
|
|
362
|
+
kill_process: "confirm",
|
|
363
|
+
// 시스템 변경 — 기본 차단 (PDF 7.3절)
|
|
364
|
+
execute_command: "deny",
|
|
365
|
+
write_file: "deny"
|
|
366
|
+
};
|
|
367
|
+
function checkPermission(toolName) {
|
|
368
|
+
const level = toolPermissions[toolName];
|
|
369
|
+
if (level === "deny") {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Tool '${toolName}' is blocked by permission policy (deny). To allow, update toolPermissions in src/server/permissions.ts.`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/tools/filesystem.ts
|
|
315
377
|
var execAsync = (0, import_util.promisify)(import_child_process.exec);
|
|
316
378
|
var execFileAsync = (0, import_util.promisify)(import_child_process.execFile);
|
|
317
379
|
var FilesystemTools = class {
|
|
318
380
|
register(server) {
|
|
319
381
|
server.tool(
|
|
320
382
|
"execute_command",
|
|
321
|
-
"
|
|
383
|
+
"Execute terminal command",
|
|
322
384
|
{
|
|
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("
|
|
385
|
+
command: import_zod.z.string().describe("Shell command to execute"),
|
|
386
|
+
timeout_ms: import_zod.z.number().optional().default(3e4).describe("Timeout (ms)"),
|
|
387
|
+
background: import_zod.z.boolean().optional().default(false).describe("Run in background")
|
|
326
388
|
},
|
|
327
389
|
async ({ command, timeout_ms, background }) => {
|
|
390
|
+
checkPermission("execute_command");
|
|
328
391
|
if (background) {
|
|
329
392
|
(0, import_child_process.exec)(command);
|
|
330
|
-
return { content: [{ type: "text", text: "
|
|
393
|
+
return { content: [{ type: "text", text: "Background execution started" }] };
|
|
331
394
|
}
|
|
332
395
|
try {
|
|
333
396
|
const { stdout, stderr } = await execAsync(command, {
|
|
334
397
|
timeout: timeout_ms
|
|
335
398
|
});
|
|
336
399
|
return {
|
|
337
|
-
content: [{ type: "text", text: stdout || stderr || "(
|
|
400
|
+
content: [{ type: "text", text: stdout || stderr || "(no output)" }]
|
|
338
401
|
};
|
|
339
402
|
} catch (err) {
|
|
340
403
|
const error = err;
|
|
@@ -342,7 +405,7 @@ var FilesystemTools = class {
|
|
|
342
405
|
content: [
|
|
343
406
|
{
|
|
344
407
|
type: "text",
|
|
345
|
-
text:
|
|
408
|
+
text: `Error (exit ${error.code ?? "?"}): ${error.message}
|
|
346
409
|
${error.stderr ?? ""}`
|
|
347
410
|
}
|
|
348
411
|
],
|
|
@@ -353,10 +416,10 @@ ${error.stderr ?? ""}`
|
|
|
353
416
|
);
|
|
354
417
|
server.tool(
|
|
355
418
|
"read_file",
|
|
356
|
-
"
|
|
419
|
+
"Read file",
|
|
357
420
|
{
|
|
358
|
-
path: import_zod.z.string().describe("
|
|
359
|
-
encoding: import_zod.z.enum(["utf-8", "base64"]).optional().default("utf-8").describe("
|
|
421
|
+
path: import_zod.z.string().describe("File path"),
|
|
422
|
+
encoding: import_zod.z.enum(["utf-8", "base64"]).optional().default("utf-8").describe("Encoding")
|
|
360
423
|
},
|
|
361
424
|
async ({ path: filePath, encoding }) => {
|
|
362
425
|
try {
|
|
@@ -365,30 +428,31 @@ ${error.stderr ?? ""}`
|
|
|
365
428
|
} catch (err) {
|
|
366
429
|
const e = err;
|
|
367
430
|
if (e.code === "ENOENT") {
|
|
368
|
-
return { content: [{ type: "text", text: `\u274C
|
|
431
|
+
return { content: [{ type: "text", text: `\u274C File not found: ${filePath}` }], isError: true };
|
|
369
432
|
}
|
|
370
|
-
return { content: [{ type: "text", text: `\u274C
|
|
433
|
+
return { content: [{ type: "text", text: `\u274C Failed to read file: ${e.message}` }], isError: true };
|
|
371
434
|
}
|
|
372
435
|
}
|
|
373
436
|
);
|
|
374
437
|
server.tool(
|
|
375
438
|
"write_file",
|
|
376
|
-
"
|
|
439
|
+
"Write/create file",
|
|
377
440
|
{
|
|
378
|
-
path: import_zod.z.string().describe("
|
|
379
|
-
content: import_zod.z.string().describe("
|
|
441
|
+
path: import_zod.z.string().describe("File path"),
|
|
442
|
+
content: import_zod.z.string().describe("File content")
|
|
380
443
|
},
|
|
381
444
|
async ({ path: filePath, content }) => {
|
|
445
|
+
checkPermission("write_file");
|
|
382
446
|
await import_promises.default.mkdir(import_path2.default.dirname(filePath), { recursive: true });
|
|
383
447
|
await import_promises.default.writeFile(filePath, content, "utf-8");
|
|
384
|
-
return { content: [{ type: "text", text: "
|
|
448
|
+
return { content: [{ type: "text", text: "File saved" }] };
|
|
385
449
|
}
|
|
386
450
|
);
|
|
387
451
|
server.tool(
|
|
388
452
|
"list_directory",
|
|
389
|
-
"
|
|
453
|
+
"List directory contents",
|
|
390
454
|
{
|
|
391
|
-
path: import_zod.z.string().describe("
|
|
455
|
+
path: import_zod.z.string().describe("Directory path")
|
|
392
456
|
},
|
|
393
457
|
async ({ path: dirPath }) => {
|
|
394
458
|
try {
|
|
@@ -398,19 +462,19 @@ ${error.stderr ?? ""}`
|
|
|
398
462
|
} catch (err) {
|
|
399
463
|
const e = err;
|
|
400
464
|
if (e.code === "ENOENT") {
|
|
401
|
-
return { content: [{ type: "text", text: `\u274C
|
|
465
|
+
return { content: [{ type: "text", text: `\u274C Directory not found: ${dirPath}` }], isError: true };
|
|
402
466
|
}
|
|
403
|
-
return { content: [{ type: "text", text: `\u274C
|
|
467
|
+
return { content: [{ type: "text", text: `\u274C Failed to read directory: ${e.message}` }], isError: true };
|
|
404
468
|
}
|
|
405
469
|
}
|
|
406
470
|
);
|
|
407
471
|
server.tool(
|
|
408
472
|
"search_code",
|
|
409
|
-
"
|
|
473
|
+
"Search code/text",
|
|
410
474
|
{
|
|
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("
|
|
475
|
+
pattern: import_zod.z.string().describe("Search pattern (regex supported)"),
|
|
476
|
+
directory: import_zod.z.string().optional().default(".").describe("Search directory"),
|
|
477
|
+
file_pattern: import_zod.z.string().optional().default("**/*").describe("File pattern")
|
|
414
478
|
},
|
|
415
479
|
async ({ pattern, directory, file_pattern }) => {
|
|
416
480
|
try {
|
|
@@ -419,7 +483,7 @@ ${error.stderr ?? ""}`
|
|
|
419
483
|
["--no-heading", "-n", pattern, directory],
|
|
420
484
|
{ timeout: 1e4 }
|
|
421
485
|
);
|
|
422
|
-
return { content: [{ type: "text", text: stdout || "
|
|
486
|
+
return { content: [{ type: "text", text: stdout || "No results" }] };
|
|
423
487
|
} catch {
|
|
424
488
|
const safeDirectory = import_path2.default.resolve(directory);
|
|
425
489
|
const files = await (0, import_glob.glob)(file_pattern, { cwd: safeDirectory });
|
|
@@ -440,7 +504,7 @@ ${error.stderr ?? ""}`
|
|
|
440
504
|
}
|
|
441
505
|
return {
|
|
442
506
|
content: [
|
|
443
|
-
{ type: "text", text: results.join("\n") || "
|
|
507
|
+
{ type: "text", text: results.join("\n") || "No results" }
|
|
444
508
|
]
|
|
445
509
|
};
|
|
446
510
|
}
|
|
@@ -448,7 +512,7 @@ ${error.stderr ?? ""}`
|
|
|
448
512
|
);
|
|
449
513
|
server.tool(
|
|
450
514
|
"list_processes",
|
|
451
|
-
"
|
|
515
|
+
"List running processes",
|
|
452
516
|
{},
|
|
453
517
|
async () => {
|
|
454
518
|
const cmd = process.platform === "win32" ? "tasklist" : process.platform === "darwin" ? "ps aux | sort -rk 3 | head -30" : "ps aux --sort=-%cpu | head -30";
|
|
@@ -458,23 +522,23 @@ ${error.stderr ?? ""}`
|
|
|
458
522
|
);
|
|
459
523
|
server.tool(
|
|
460
524
|
"kill_process",
|
|
461
|
-
"
|
|
525
|
+
"Kill process (SIGTERM then 3s wait, auto SIGKILL if still alive)",
|
|
462
526
|
{
|
|
463
|
-
pid: import_zod.z.number().describe("
|
|
464
|
-
signal: import_zod.z.enum(["SIGTERM", "SIGKILL"]).optional().default("SIGTERM").describe("
|
|
527
|
+
pid: import_zod.z.number().describe("PID of the process to kill"),
|
|
528
|
+
signal: import_zod.z.enum(["SIGTERM", "SIGKILL"]).optional().default("SIGTERM").describe("Initial signal (default: SIGTERM). SIGKILL for immediate force kill)")
|
|
465
529
|
},
|
|
466
530
|
async ({ pid, signal }) => {
|
|
467
531
|
const isWindows = process.platform === "win32";
|
|
468
532
|
if (isWindows) {
|
|
469
533
|
await execAsync(`taskkill /PID ${pid} /F`);
|
|
470
534
|
return {
|
|
471
|
-
content: [{ type: "text", text: `PID ${pid}
|
|
535
|
+
content: [{ type: "text", text: `PID ${pid} killed (taskkill /F)` }]
|
|
472
536
|
};
|
|
473
537
|
}
|
|
474
538
|
if (signal === "SIGKILL") {
|
|
475
539
|
await execAsync(`kill -9 ${pid}`);
|
|
476
540
|
return {
|
|
477
|
-
content: [{ type: "text", text: `PID ${pid}
|
|
541
|
+
content: [{ type: "text", text: `PID ${pid} force killed (SIGKILL)` }]
|
|
478
542
|
};
|
|
479
543
|
}
|
|
480
544
|
try {
|
|
@@ -482,7 +546,7 @@ ${error.stderr ?? ""}`
|
|
|
482
546
|
} catch {
|
|
483
547
|
return {
|
|
484
548
|
content: [
|
|
485
|
-
{ type: "text", text: `PID ${pid}
|
|
549
|
+
{ type: "text", text: `PID ${pid} kill failed: process does not exist or permission denied.` }
|
|
486
550
|
],
|
|
487
551
|
isError: true
|
|
488
552
|
};
|
|
@@ -491,7 +555,7 @@ ${error.stderr ?? ""}`
|
|
|
491
555
|
const isAlive = await execAsync(`kill -0 ${pid}`).then(() => true).catch(() => false);
|
|
492
556
|
if (!isAlive) {
|
|
493
557
|
return {
|
|
494
|
-
content: [{ type: "text", text: `PID ${pid}
|
|
558
|
+
content: [{ type: "text", text: `PID ${pid} killed (SIGTERM)` }]
|
|
495
559
|
};
|
|
496
560
|
}
|
|
497
561
|
await execAsync(`kill -9 ${pid}`);
|
|
@@ -499,7 +563,7 @@ ${error.stderr ?? ""}`
|
|
|
499
563
|
content: [
|
|
500
564
|
{
|
|
501
565
|
type: "text",
|
|
502
|
-
text: `PID ${pid}
|
|
566
|
+
text: `PID ${pid} force killed (SIGTERM unresponsive, auto SIGKILL applied)`
|
|
503
567
|
}
|
|
504
568
|
]
|
|
505
569
|
};
|
|
@@ -507,17 +571,17 @@ ${error.stderr ?? ""}`
|
|
|
507
571
|
);
|
|
508
572
|
server.tool(
|
|
509
573
|
"edit_block",
|
|
510
|
-
"
|
|
574
|
+
"Replace a specific text block in a file with new text (diff-based partial edit)",
|
|
511
575
|
{
|
|
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
|
|
576
|
+
path: import_zod.z.string().describe("File path"),
|
|
577
|
+
old_string: import_zod.z.string().describe("Existing text to replace (must match exactly)"),
|
|
578
|
+
new_string: import_zod.z.string().describe("New text"),
|
|
579
|
+
replace_all: import_zod.z.boolean().optional().default(false).describe("If true, replace all matches; if false, replace only the first")
|
|
516
580
|
},
|
|
517
581
|
async ({ path: filePath, old_string, new_string, replace_all }) => {
|
|
518
582
|
const content = await import_promises.default.readFile(filePath, "utf-8");
|
|
519
583
|
if (!content.includes(old_string)) {
|
|
520
|
-
throw new Error(`old_string
|
|
584
|
+
throw new Error(`old_string not found in file: ${filePath}`);
|
|
521
585
|
}
|
|
522
586
|
let count = 0;
|
|
523
587
|
let pos = 0;
|
|
@@ -527,7 +591,7 @@ ${error.stderr ?? ""}`
|
|
|
527
591
|
}
|
|
528
592
|
if (!replace_all && count > 1) {
|
|
529
593
|
throw new Error(
|
|
530
|
-
|
|
594
|
+
`Found ${count} matches. Set replace_all to true or include more context to narrow it down.`
|
|
531
595
|
);
|
|
532
596
|
}
|
|
533
597
|
let result;
|
|
@@ -541,21 +605,197 @@ ${error.stderr ?? ""}`
|
|
|
541
605
|
}
|
|
542
606
|
await import_promises.default.writeFile(filePath, result, "utf-8");
|
|
543
607
|
return {
|
|
544
|
-
content: [{ type: "text", text:
|
|
608
|
+
content: [{ type: "text", text: `Replaced (${replaced} occurrence(s) changed)` }]
|
|
545
609
|
};
|
|
546
610
|
}
|
|
547
611
|
);
|
|
612
|
+
server.tool(
|
|
613
|
+
"cron_create",
|
|
614
|
+
"Create a recurring cron job. schedule uses cron syntax (e.g. '0 9 * * 1-5' = weekdays 9am).",
|
|
615
|
+
{
|
|
616
|
+
schedule: import_zod.z.string().describe("Cron schedule expression (e.g. '*/5 * * * *' for every 5 min, '0 9 * * 1-5' for weekdays 9am)"),
|
|
617
|
+
command: import_zod.z.string().describe("Shell command to execute"),
|
|
618
|
+
label: import_zod.z.string().optional().describe("Optional label/comment for identification")
|
|
619
|
+
},
|
|
620
|
+
async ({ schedule, command, label }) => {
|
|
621
|
+
try {
|
|
622
|
+
let existing = "";
|
|
623
|
+
try {
|
|
624
|
+
const { stdout } = await execAsync("crontab -l");
|
|
625
|
+
existing = stdout;
|
|
626
|
+
} catch {
|
|
627
|
+
}
|
|
628
|
+
if (existing.includes(command)) {
|
|
629
|
+
return {
|
|
630
|
+
content: [{ type: "text", text: `\u26A0\uFE0F A cron job with this command already exists.` }],
|
|
631
|
+
isError: true
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
const comment = label ? `# junis:${label}
|
|
635
|
+
` : "# junis-cron\n";
|
|
636
|
+
const newEntry = `${comment}${schedule} ${command}
|
|
637
|
+
`;
|
|
638
|
+
const updated = existing.trimEnd() + "\n" + newEntry;
|
|
639
|
+
const tmpFile = `/tmp/junis_crontab_${Date.now()}.txt`;
|
|
640
|
+
await import_promises.default.writeFile(tmpFile, updated, "utf-8");
|
|
641
|
+
await execAsync(`crontab ${tmpFile}`);
|
|
642
|
+
await import_promises.default.unlink(tmpFile).catch(() => {
|
|
643
|
+
});
|
|
644
|
+
return {
|
|
645
|
+
content: [{ type: "text", text: `\u2705 Cron job created:
|
|
646
|
+
schedule: ${schedule}
|
|
647
|
+
command: ${command}${label ? `
|
|
648
|
+
label: ${label}` : ""}` }]
|
|
649
|
+
};
|
|
650
|
+
} catch (err) {
|
|
651
|
+
return {
|
|
652
|
+
content: [{ type: "text", text: `\u274C Failed to create cron job: ${err.message}` }],
|
|
653
|
+
isError: true
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
);
|
|
658
|
+
server.tool(
|
|
659
|
+
"cron_list",
|
|
660
|
+
"List all cron jobs in the current user's crontab",
|
|
661
|
+
{},
|
|
662
|
+
async () => {
|
|
663
|
+
try {
|
|
664
|
+
const { stdout } = await execAsync("crontab -l");
|
|
665
|
+
const lines = stdout.trim().split("\n").filter((l) => l.trim());
|
|
666
|
+
if (lines.length === 0) {
|
|
667
|
+
return { content: [{ type: "text", text: "No cron jobs found." }] };
|
|
668
|
+
}
|
|
669
|
+
const entries = [];
|
|
670
|
+
let pendingLabel;
|
|
671
|
+
let id = 1;
|
|
672
|
+
for (const line of lines) {
|
|
673
|
+
if (line.startsWith("#")) {
|
|
674
|
+
const match = line.match(/^# junis:(.+)$/);
|
|
675
|
+
pendingLabel = match ? match[1].trim() : void 0;
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const parts = line.split(/\s+/);
|
|
679
|
+
if (parts.length >= 6) {
|
|
680
|
+
const schedule = parts.slice(0, 5).join(" ");
|
|
681
|
+
const command = parts.slice(5).join(" ");
|
|
682
|
+
entries.push({ id: id++, label: pendingLabel, schedule, command });
|
|
683
|
+
}
|
|
684
|
+
pendingLabel = void 0;
|
|
685
|
+
}
|
|
686
|
+
if (entries.length === 0) {
|
|
687
|
+
return { content: [{ type: "text", text: stdout }] };
|
|
688
|
+
}
|
|
689
|
+
const output = entries.map(
|
|
690
|
+
(e) => `[${e.id}] ${e.label ? `(${e.label}) ` : ""}${e.schedule} \u2192 ${e.command}`
|
|
691
|
+
).join("\n");
|
|
692
|
+
return { content: [{ type: "text", text: output }] };
|
|
693
|
+
} catch (err) {
|
|
694
|
+
const e = err;
|
|
695
|
+
if (e.code === 1) {
|
|
696
|
+
return { content: [{ type: "text", text: "No cron jobs found (crontab is empty)." }] };
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
content: [{ type: "text", text: `\u274C Failed to list cron jobs: ${e.message}` }],
|
|
700
|
+
isError: true
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
);
|
|
705
|
+
server.tool(
|
|
706
|
+
"cron_delete",
|
|
707
|
+
"Delete a cron job by its ID (from cron_list) or by matching command string",
|
|
708
|
+
{
|
|
709
|
+
id: import_zod.z.number().optional().describe("Cron job ID from cron_list output"),
|
|
710
|
+
command: import_zod.z.string().optional().describe("Delete job matching this command string")
|
|
711
|
+
},
|
|
712
|
+
async ({ id, command }) => {
|
|
713
|
+
if (!id && !command) {
|
|
714
|
+
return {
|
|
715
|
+
content: [{ type: "text", text: "\u274C Provide either id or command to identify the cron job." }],
|
|
716
|
+
isError: true
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
let existing = "";
|
|
721
|
+
try {
|
|
722
|
+
const { stdout } = await execAsync("crontab -l");
|
|
723
|
+
existing = stdout;
|
|
724
|
+
} catch {
|
|
725
|
+
return { content: [{ type: "text", text: "No cron jobs to delete." }] };
|
|
726
|
+
}
|
|
727
|
+
const lines = existing.split("\n");
|
|
728
|
+
if (command) {
|
|
729
|
+
const filtered2 = [];
|
|
730
|
+
for (let i = 0; i < lines.length; i++) {
|
|
731
|
+
if (lines[i].includes(command)) {
|
|
732
|
+
if (filtered2.length > 0 && filtered2[filtered2.length - 1].trim().startsWith("#")) {
|
|
733
|
+
filtered2.pop();
|
|
734
|
+
}
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
filtered2.push(lines[i]);
|
|
738
|
+
}
|
|
739
|
+
if (filtered2.length === lines.length) {
|
|
740
|
+
return {
|
|
741
|
+
content: [{ type: "text", text: `\u274C No cron job found matching: ${command}` }],
|
|
742
|
+
isError: true
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
const updated2 = filtered2.join("\n");
|
|
746
|
+
const tmpFile2 = `/tmp/junis_crontab_${Date.now()}.txt`;
|
|
747
|
+
await import_promises.default.writeFile(tmpFile2, updated2, "utf-8");
|
|
748
|
+
await execAsync(`crontab ${tmpFile2}`);
|
|
749
|
+
await import_promises.default.unlink(tmpFile2).catch(() => {
|
|
750
|
+
});
|
|
751
|
+
return { content: [{ type: "text", text: `\u2705 Deleted cron job matching: ${command}` }] };
|
|
752
|
+
}
|
|
753
|
+
const entries = [];
|
|
754
|
+
let idx = 1;
|
|
755
|
+
for (let i = 0; i < lines.length; i++) {
|
|
756
|
+
const line = lines[i].trim();
|
|
757
|
+
if (line.startsWith("#")) continue;
|
|
758
|
+
const parts = line.split(/\s+/);
|
|
759
|
+
if (parts.length >= 6) {
|
|
760
|
+
const prevIsComment = i > 0 && lines[i - 1].trim().startsWith("#");
|
|
761
|
+
entries.push({ lineStart: prevIsComment ? i - 1 : i, lineEnd: i, idx: idx++ });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const target = entries.find((e) => e.idx === id);
|
|
765
|
+
if (!target) {
|
|
766
|
+
return {
|
|
767
|
+
content: [{ type: "text", text: `\u274C No cron job found with id=${id}. Use cron_list to see current IDs.` }],
|
|
768
|
+
isError: true
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
const filtered = lines.filter((_, i) => i < target.lineStart || i > target.lineEnd);
|
|
772
|
+
const updated = filtered.join("\n");
|
|
773
|
+
const tmpFile = `/tmp/junis_crontab_${Date.now()}.txt`;
|
|
774
|
+
await import_promises.default.writeFile(tmpFile, updated, "utf-8");
|
|
775
|
+
await execAsync(`crontab ${tmpFile}`);
|
|
776
|
+
await import_promises.default.unlink(tmpFile).catch(() => {
|
|
777
|
+
});
|
|
778
|
+
return { content: [{ type: "text", text: `\u2705 Deleted cron job #${id}` }] };
|
|
779
|
+
} catch (err) {
|
|
780
|
+
return {
|
|
781
|
+
content: [{ type: "text", text: `\u274C Failed to delete cron job: ${err.message}` }],
|
|
782
|
+
isError: true
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
);
|
|
548
787
|
}
|
|
549
788
|
};
|
|
550
789
|
|
|
551
790
|
// src/tools/browser.ts
|
|
552
|
-
var
|
|
791
|
+
var import_browserclaw = require("browserclaw");
|
|
792
|
+
var import_promises2 = __toESM(require("fs/promises"));
|
|
553
793
|
var import_zod2 = require("zod");
|
|
554
794
|
var BrowserTools = class {
|
|
555
795
|
browser = null;
|
|
556
796
|
page = null;
|
|
557
|
-
// 동시 요청 시 race condition 방지용 직렬화 락
|
|
558
797
|
lock = Promise.resolve();
|
|
798
|
+
armedDialog = null;
|
|
559
799
|
withLock(fn) {
|
|
560
800
|
let release;
|
|
561
801
|
const next = new Promise((r) => {
|
|
@@ -565,128 +805,373 @@ var BrowserTools = class {
|
|
|
565
805
|
this.lock = this.lock.then(() => next);
|
|
566
806
|
return current.then(() => fn()).finally(() => release());
|
|
567
807
|
}
|
|
808
|
+
/** mcp.ts에서 호출하는 init — BrowserClaw는 browser_start 도구로 명시적 시작하므로 noop */
|
|
568
809
|
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
810
|
}
|
|
578
811
|
async cleanup() {
|
|
579
|
-
await this.browser?.
|
|
812
|
+
await this.browser?.stop();
|
|
813
|
+
this.browser = null;
|
|
814
|
+
this.page = null;
|
|
580
815
|
}
|
|
581
816
|
register(server) {
|
|
582
817
|
const requirePage = () => {
|
|
583
|
-
if (!this.page) throw new Error("
|
|
818
|
+
if (!this.page) throw new Error("Browser not started. Call browser_start first.");
|
|
584
819
|
return this.page;
|
|
585
820
|
};
|
|
821
|
+
server.tool(
|
|
822
|
+
"browser_start",
|
|
823
|
+
"Start browser (BrowserClaw). mode='managed'(default) launches new Chromium; mode='remote-cdp' connects to existing Chrome via CDP URL.",
|
|
824
|
+
{
|
|
825
|
+
mode: import_zod2.z.enum(["managed", "remote-cdp"]).optional().default("managed").describe("'managed' = launch new browser, 'remote-cdp' = connect to existing Chrome"),
|
|
826
|
+
headless: import_zod2.z.boolean().optional().default(false).describe("Run headless (managed mode only)"),
|
|
827
|
+
cdpUrl: import_zod2.z.string().optional().describe("CDP URL for remote-cdp mode (e.g. http://localhost:9222)"),
|
|
828
|
+
profile: import_zod2.z.string().optional().describe("Profile name (managed mode only)"),
|
|
829
|
+
allowInternal: import_zod2.z.boolean().optional().default(false).describe("Allow localhost/internal URLs")
|
|
830
|
+
},
|
|
831
|
+
({ mode, headless, cdpUrl, profile, allowInternal }) => this.withLock(async () => {
|
|
832
|
+
if (this.browser) {
|
|
833
|
+
return { content: [{ type: "text", text: "Browser is already running. Call browser_stop first." }] };
|
|
834
|
+
}
|
|
835
|
+
if (mode === "remote-cdp") {
|
|
836
|
+
if (!cdpUrl) throw new Error("cdpUrl is required for remote-cdp mode");
|
|
837
|
+
this.browser = await import_browserclaw.BrowserClaw.connect(cdpUrl, { allowInternal });
|
|
838
|
+
} else {
|
|
839
|
+
this.browser = await import_browserclaw.BrowserClaw.launch({
|
|
840
|
+
headless,
|
|
841
|
+
profileName: profile,
|
|
842
|
+
allowInternal
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
return { content: [{ type: "text", text: `Browser started (mode: ${mode})` }] };
|
|
846
|
+
})
|
|
847
|
+
);
|
|
848
|
+
server.tool(
|
|
849
|
+
"browser_stop",
|
|
850
|
+
"Stop browser and release resources",
|
|
851
|
+
{},
|
|
852
|
+
() => this.withLock(async () => {
|
|
853
|
+
await this.cleanup();
|
|
854
|
+
return { content: [{ type: "text", text: "Browser stopped" }] };
|
|
855
|
+
})
|
|
856
|
+
);
|
|
586
857
|
server.tool(
|
|
587
858
|
"browser_navigate",
|
|
588
|
-
"URL
|
|
589
|
-
{
|
|
859
|
+
"Navigate to URL. Opens new tab if browser started but no page yet.",
|
|
860
|
+
{
|
|
861
|
+
url: import_zod2.z.string().describe("URL to navigate to")
|
|
862
|
+
},
|
|
590
863
|
({ url }) => this.withLock(async () => {
|
|
591
|
-
|
|
592
|
-
|
|
864
|
+
if (!this.browser) throw new Error("Browser not started. Call browser_start first.");
|
|
865
|
+
if (!this.page) {
|
|
866
|
+
this.page = await this.browser.open(url);
|
|
867
|
+
} else {
|
|
868
|
+
await this.page.goto(url);
|
|
869
|
+
}
|
|
870
|
+
const currentUrl = await this.page.url();
|
|
871
|
+
return { content: [{ type: "text", text: `Navigated to: ${currentUrl}` }] };
|
|
872
|
+
})
|
|
873
|
+
);
|
|
874
|
+
server.tool(
|
|
875
|
+
"browser_snapshot",
|
|
876
|
+
"Get Accessibility Tree snapshot with ref numbers. Use refs to interact with elements (e.g. browser_click with ref='e1').",
|
|
877
|
+
{
|
|
878
|
+
interactive: import_zod2.z.boolean().optional().default(true).describe("Only include interactive elements"),
|
|
879
|
+
compact: import_zod2.z.boolean().optional().default(true).describe("Remove empty containers")
|
|
880
|
+
},
|
|
881
|
+
({ interactive, compact }) => this.withLock(async () => {
|
|
882
|
+
const result = await requirePage().snapshot({ interactive, compact });
|
|
883
|
+
const { snapshot, refs, stats } = result;
|
|
884
|
+
const refList = Object.entries(refs).map(([r, info]) => ` ${r}: ${info.role} "${info.name ?? ""}"`).join("\n");
|
|
885
|
+
const total = stats?.refs ?? Object.keys(refs).length;
|
|
593
886
|
return {
|
|
594
|
-
content: [{
|
|
887
|
+
content: [{
|
|
888
|
+
type: "text",
|
|
889
|
+
text: `${snapshot}
|
|
890
|
+
|
|
891
|
+
--- refs (${total} total) ---
|
|
892
|
+
${refList}`
|
|
893
|
+
}]
|
|
595
894
|
};
|
|
596
895
|
})
|
|
597
896
|
);
|
|
598
897
|
server.tool(
|
|
599
898
|
"browser_click",
|
|
600
|
-
"
|
|
601
|
-
{
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
899
|
+
"Click element by ref number from browser_snapshot",
|
|
900
|
+
{
|
|
901
|
+
ref: import_zod2.z.string().describe("Ref number from snapshot (e.g. 'e1')"),
|
|
902
|
+
doubleClick: import_zod2.z.boolean().optional().default(false),
|
|
903
|
+
button: import_zod2.z.enum(["left", "right", "middle"]).optional().default("left")
|
|
904
|
+
},
|
|
905
|
+
({ ref, doubleClick, button }) => this.withLock(async () => {
|
|
906
|
+
await requirePage().click(ref, { doubleClick, button });
|
|
907
|
+
return { content: [{ type: "text", text: `Clicked ref=${ref}` }] };
|
|
605
908
|
})
|
|
606
909
|
);
|
|
607
910
|
server.tool(
|
|
608
911
|
"browser_type",
|
|
609
|
-
"
|
|
912
|
+
"Type text into element by ref number",
|
|
610
913
|
{
|
|
611
|
-
|
|
612
|
-
text: import_zod2.z.string().describe("
|
|
613
|
-
|
|
914
|
+
ref: import_zod2.z.string().describe("Ref number from snapshot"),
|
|
915
|
+
text: import_zod2.z.string().describe("Text to type"),
|
|
916
|
+
submit: import_zod2.z.boolean().optional().default(false).describe("Press Enter after typing"),
|
|
917
|
+
slowly: import_zod2.z.boolean().optional().default(false).describe("Type slowly (75ms per char)")
|
|
614
918
|
},
|
|
615
|
-
({
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
919
|
+
({ ref, text, submit, slowly }) => this.withLock(async () => {
|
|
920
|
+
await requirePage().type(ref, text, { submit, slowly });
|
|
921
|
+
return { content: [{ type: "text", text: `Typed into ref=${ref}` }] };
|
|
922
|
+
})
|
|
923
|
+
);
|
|
924
|
+
server.tool(
|
|
925
|
+
"browser_fill",
|
|
926
|
+
"Fill multiple form fields at once",
|
|
927
|
+
{
|
|
928
|
+
fields: import_zod2.z.array(import_zod2.z.object({
|
|
929
|
+
ref: import_zod2.z.string(),
|
|
930
|
+
type: import_zod2.z.enum(["text", "checkbox", "radio"]),
|
|
931
|
+
value: import_zod2.z.union([import_zod2.z.string(), import_zod2.z.boolean()])
|
|
932
|
+
})).describe("Array of {ref, type, value}")
|
|
933
|
+
},
|
|
934
|
+
({ fields }) => this.withLock(async () => {
|
|
935
|
+
await requirePage().fill(fields);
|
|
936
|
+
return { content: [{ type: "text", text: `Filled ${fields.length} field(s)` }] };
|
|
937
|
+
})
|
|
938
|
+
);
|
|
939
|
+
server.tool(
|
|
940
|
+
"browser_select",
|
|
941
|
+
"Select dropdown option(s) by ref",
|
|
942
|
+
{
|
|
943
|
+
ref: import_zod2.z.string().describe("Ref number from snapshot"),
|
|
944
|
+
values: import_zod2.z.array(import_zod2.z.string()).describe("Option value(s) to select")
|
|
945
|
+
},
|
|
946
|
+
({ ref, values }) => this.withLock(async () => {
|
|
947
|
+
await requirePage().select(ref, ...values);
|
|
948
|
+
return { content: [{ type: "text", text: `Selected option(s) in ref=${ref}` }] };
|
|
949
|
+
})
|
|
950
|
+
);
|
|
951
|
+
server.tool(
|
|
952
|
+
"browser_press",
|
|
953
|
+
"Press keyboard key or combination (e.g. 'Enter', 'Control+a', 'Escape')",
|
|
954
|
+
{
|
|
955
|
+
key: import_zod2.z.string().describe("Key combination (e.g. 'Enter', 'Control+a', 'Escape', 'Tab')")
|
|
956
|
+
},
|
|
957
|
+
({ key }) => this.withLock(async () => {
|
|
958
|
+
await requirePage().press(key);
|
|
959
|
+
return { content: [{ type: "text", text: `Pressed: ${key}` }] };
|
|
960
|
+
})
|
|
961
|
+
);
|
|
962
|
+
server.tool(
|
|
963
|
+
"browser_hover",
|
|
964
|
+
"Hover mouse over element by ref",
|
|
965
|
+
{
|
|
966
|
+
ref: import_zod2.z.string().describe("Ref number from snapshot")
|
|
967
|
+
},
|
|
968
|
+
({ ref }) => this.withLock(async () => {
|
|
969
|
+
await requirePage().hover(ref);
|
|
970
|
+
return { content: [{ type: "text", text: `Hovered over ref=${ref}` }] };
|
|
971
|
+
})
|
|
972
|
+
);
|
|
973
|
+
server.tool(
|
|
974
|
+
"browser_drag",
|
|
975
|
+
"Drag element from startRef to endRef",
|
|
976
|
+
{
|
|
977
|
+
startRef: import_zod2.z.string().describe("Source element ref"),
|
|
978
|
+
endRef: import_zod2.z.string().describe("Target element ref")
|
|
979
|
+
},
|
|
980
|
+
({ startRef, endRef }) => this.withLock(async () => {
|
|
981
|
+
await requirePage().drag(startRef, endRef);
|
|
982
|
+
return { content: [{ type: "text", text: `Dragged ref=${startRef} \u2192 ref=${endRef}` }] };
|
|
983
|
+
})
|
|
984
|
+
);
|
|
985
|
+
server.tool(
|
|
986
|
+
"browser_upload",
|
|
987
|
+
"Upload file(s) to file input element by ref",
|
|
988
|
+
{
|
|
989
|
+
ref: import_zod2.z.string().describe("Ref number of file input element"),
|
|
990
|
+
paths: import_zod2.z.array(import_zod2.z.string()).describe("Absolute file path(s) to upload")
|
|
991
|
+
},
|
|
992
|
+
({ ref, paths }) => this.withLock(async () => {
|
|
993
|
+
await requirePage().uploadFile(ref, paths);
|
|
994
|
+
return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ref=${ref}` }] };
|
|
620
995
|
})
|
|
621
996
|
);
|
|
622
997
|
server.tool(
|
|
623
998
|
"browser_screenshot",
|
|
624
|
-
"
|
|
999
|
+
"Take screenshot of current page",
|
|
625
1000
|
{
|
|
626
|
-
path: import_zod2.z.string().optional().describe("
|
|
627
|
-
|
|
1001
|
+
path: import_zod2.z.string().optional().describe("Save path (if omitted, returns base64)"),
|
|
1002
|
+
fullPage: import_zod2.z.boolean().optional().default(false),
|
|
1003
|
+
ref: import_zod2.z.string().optional().describe("Capture specific element by ref")
|
|
628
1004
|
},
|
|
629
|
-
({ path: path4,
|
|
630
|
-
const
|
|
631
|
-
const screenshot = await page.screenshot({
|
|
632
|
-
path: path4 ?? void 0,
|
|
633
|
-
fullPage: full_page
|
|
634
|
-
});
|
|
1005
|
+
({ path: path4, fullPage, ref }) => this.withLock(async () => {
|
|
1006
|
+
const buffer = await requirePage().screenshot({ fullPage, ref });
|
|
635
1007
|
if (path4) {
|
|
636
|
-
|
|
1008
|
+
await import_promises2.default.writeFile(path4, buffer);
|
|
1009
|
+
return { content: [{ type: "text", text: `Screenshot saved: ${path4}` }] };
|
|
637
1010
|
}
|
|
638
1011
|
return {
|
|
639
|
-
content: [
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
]
|
|
1012
|
+
content: [{
|
|
1013
|
+
type: "image",
|
|
1014
|
+
data: buffer.toString("base64"),
|
|
1015
|
+
mimeType: "image/png"
|
|
1016
|
+
}]
|
|
646
1017
|
};
|
|
647
1018
|
})
|
|
648
1019
|
);
|
|
649
1020
|
server.tool(
|
|
650
|
-
"
|
|
651
|
-
"
|
|
652
|
-
{
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
]
|
|
660
|
-
};
|
|
1021
|
+
"browser_pdf",
|
|
1022
|
+
"Save current page as PDF",
|
|
1023
|
+
{
|
|
1024
|
+
path: import_zod2.z.string().describe("Save path (.pdf)")
|
|
1025
|
+
},
|
|
1026
|
+
({ path: path4 }) => this.withLock(async () => {
|
|
1027
|
+
const buffer = await requirePage().pdf();
|
|
1028
|
+
await import_promises2.default.writeFile(path4, buffer);
|
|
1029
|
+
return { content: [{ type: "text", text: `PDF saved: ${path4}` }] };
|
|
661
1030
|
})
|
|
662
1031
|
);
|
|
663
1032
|
server.tool(
|
|
664
1033
|
"browser_evaluate",
|
|
665
|
-
"JavaScript
|
|
666
|
-
{
|
|
1034
|
+
"Execute JavaScript in page context",
|
|
1035
|
+
{
|
|
1036
|
+
code: import_zod2.z.string().describe("JavaScript code to execute (wrap in function if needed)")
|
|
1037
|
+
},
|
|
667
1038
|
({ code }) => this.withLock(async () => {
|
|
668
1039
|
try {
|
|
669
1040
|
const result = await requirePage().evaluate(code);
|
|
670
1041
|
return {
|
|
671
|
-
content: [
|
|
672
|
-
|
|
673
|
-
|
|
1042
|
+
content: [{
|
|
1043
|
+
type: "text",
|
|
1044
|
+
text: typeof result === "string" ? result : JSON.stringify(result, null, 2)
|
|
1045
|
+
}]
|
|
674
1046
|
};
|
|
675
1047
|
} catch (err) {
|
|
676
1048
|
return {
|
|
677
|
-
content: [{ type: "text", text: `\u274C
|
|
1049
|
+
content: [{ type: "text", text: `\u274C JS error: ${err.message}` }],
|
|
678
1050
|
isError: true
|
|
679
1051
|
};
|
|
680
1052
|
}
|
|
681
1053
|
})
|
|
682
1054
|
);
|
|
683
1055
|
server.tool(
|
|
684
|
-
"
|
|
685
|
-
"
|
|
686
|
-
{
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
1056
|
+
"browser_wait",
|
|
1057
|
+
"Wait for a condition: text appearance/disappearance, URL pattern, or fixed time",
|
|
1058
|
+
{
|
|
1059
|
+
text: import_zod2.z.string().optional().describe("Wait until this text appears"),
|
|
1060
|
+
textGone: import_zod2.z.string().optional().describe("Wait until this text disappears"),
|
|
1061
|
+
url: import_zod2.z.string().optional().describe("Wait until URL matches (glob pattern, e.g. '**/dashboard')"),
|
|
1062
|
+
loadState: import_zod2.z.enum(["load", "domcontentloaded", "networkidle"]).optional().describe("Wait for load state"),
|
|
1063
|
+
timeMs: import_zod2.z.number().optional().describe("Wait fixed milliseconds")
|
|
1064
|
+
},
|
|
1065
|
+
({ text, textGone, url, loadState, timeMs }) => this.withLock(async () => {
|
|
1066
|
+
const condition = {};
|
|
1067
|
+
if (text) condition.text = text;
|
|
1068
|
+
if (textGone) condition.textGone = textGone;
|
|
1069
|
+
if (url) condition.url = url;
|
|
1070
|
+
if (loadState) condition.loadState = loadState;
|
|
1071
|
+
if (timeMs) condition.timeMs = timeMs;
|
|
1072
|
+
await requirePage().waitFor(condition);
|
|
1073
|
+
return { content: [{ type: "text", text: "Wait condition met" }] };
|
|
1074
|
+
})
|
|
1075
|
+
);
|
|
1076
|
+
server.tool(
|
|
1077
|
+
"browser_cookies",
|
|
1078
|
+
"Get, set, or clear cookies",
|
|
1079
|
+
{
|
|
1080
|
+
action: import_zod2.z.enum(["get", "set", "clear"]).describe("Action to perform"),
|
|
1081
|
+
cookie: import_zod2.z.object({
|
|
1082
|
+
name: import_zod2.z.string(),
|
|
1083
|
+
value: import_zod2.z.string(),
|
|
1084
|
+
domain: import_zod2.z.string().optional(),
|
|
1085
|
+
path: import_zod2.z.string().optional(),
|
|
1086
|
+
httpOnly: import_zod2.z.boolean().optional(),
|
|
1087
|
+
secure: import_zod2.z.boolean().optional()
|
|
1088
|
+
}).optional().describe("Cookie data (required for set action)")
|
|
1089
|
+
},
|
|
1090
|
+
({ action, cookie }) => this.withLock(async () => {
|
|
1091
|
+
const page = requirePage();
|
|
1092
|
+
if (action === "get") {
|
|
1093
|
+
const cookies = await page.cookies();
|
|
1094
|
+
return { content: [{ type: "text", text: JSON.stringify(cookies, null, 2) }] };
|
|
1095
|
+
} else if (action === "set") {
|
|
1096
|
+
if (!cookie) throw new Error("cookie is required for set action");
|
|
1097
|
+
await page.setCookie({ path: "/", ...cookie });
|
|
1098
|
+
return { content: [{ type: "text", text: `Cookie set: ${cookie.name}` }] };
|
|
1099
|
+
} else {
|
|
1100
|
+
await page.clearCookies();
|
|
1101
|
+
return { content: [{ type: "text", text: "All cookies cleared" }] };
|
|
1102
|
+
}
|
|
1103
|
+
})
|
|
1104
|
+
);
|
|
1105
|
+
server.tool(
|
|
1106
|
+
"browser_storage",
|
|
1107
|
+
"Read/write/clear localStorage or sessionStorage",
|
|
1108
|
+
{
|
|
1109
|
+
action: import_zod2.z.enum(["get", "set", "clear"]).describe("Action to perform"),
|
|
1110
|
+
kind: import_zod2.z.enum(["local", "session"]).optional().default("local").describe("Storage type"),
|
|
1111
|
+
key: import_zod2.z.string().optional().describe("Storage key (get/set)"),
|
|
1112
|
+
value: import_zod2.z.string().optional().describe("Value to set (set action)")
|
|
1113
|
+
},
|
|
1114
|
+
({ action, kind, key, value }) => this.withLock(async () => {
|
|
1115
|
+
const page = requirePage();
|
|
1116
|
+
if (action === "get") {
|
|
1117
|
+
const result = await page.storageGet(kind, key);
|
|
1118
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1119
|
+
} else if (action === "set") {
|
|
1120
|
+
if (!key || value === void 0) throw new Error("key and value are required for set action");
|
|
1121
|
+
await page.storageSet(kind, key, value);
|
|
1122
|
+
return { content: [{ type: "text", text: `Storage set: ${key}` }] };
|
|
1123
|
+
} else {
|
|
1124
|
+
await page.storageClear(kind);
|
|
1125
|
+
return { content: [{ type: "text", text: `${kind}Storage cleared` }] };
|
|
1126
|
+
}
|
|
1127
|
+
})
|
|
1128
|
+
);
|
|
1129
|
+
server.tool(
|
|
1130
|
+
"browser_dialog",
|
|
1131
|
+
[
|
|
1132
|
+
"Handle JavaScript dialogs (alert/confirm/prompt).",
|
|
1133
|
+
"Two-step usage:",
|
|
1134
|
+
" 1. action='arm' \u2014 register a one-shot handler (returns immediately, does NOT block).",
|
|
1135
|
+
" 2. Trigger the dialog (e.g. browser_click on the button that calls confirm()).",
|
|
1136
|
+
" 3. action='wait' \u2014 await the handler to confirm the dialog was handled.",
|
|
1137
|
+
"The 'accept' and 'promptText' params are only used with action='arm'."
|
|
1138
|
+
].join(" "),
|
|
1139
|
+
{
|
|
1140
|
+
action: import_zod2.z.enum(["arm", "wait"]).describe(
|
|
1141
|
+
"'arm' = register handler and return immediately; 'wait' = await the previously armed handler"
|
|
1142
|
+
),
|
|
1143
|
+
accept: import_zod2.z.boolean().optional().default(true).describe(
|
|
1144
|
+
"Accept (true) or dismiss (false) the dialog. Only used with action='arm'."
|
|
1145
|
+
),
|
|
1146
|
+
promptText: import_zod2.z.string().optional().describe(
|
|
1147
|
+
"Text to enter if the dialog is a prompt. Only used with action='arm'."
|
|
1148
|
+
),
|
|
1149
|
+
timeoutMs: import_zod2.z.number().optional().describe(
|
|
1150
|
+
"Timeout in ms for 'wait' action. Default: 30000."
|
|
1151
|
+
)
|
|
1152
|
+
},
|
|
1153
|
+
({ action, accept, promptText, timeoutMs }) => this.withLock(async () => {
|
|
1154
|
+
if (action === "arm") {
|
|
1155
|
+
this.armedDialog = requirePage().armDialog({
|
|
1156
|
+
accept: accept ?? true,
|
|
1157
|
+
promptText,
|
|
1158
|
+
timeoutMs
|
|
1159
|
+
});
|
|
1160
|
+
this.armedDialog.catch(() => {
|
|
1161
|
+
});
|
|
1162
|
+
return { content: [{ type: "text", text: "Dialog handler armed. Trigger the dialog now, then call browser_dialog with action='wait'." }] };
|
|
1163
|
+
} else {
|
|
1164
|
+
if (!this.armedDialog) {
|
|
1165
|
+
return {
|
|
1166
|
+
content: [{ type: "text", text: "No dialog handler is armed. Call browser_dialog with action='arm' first." }],
|
|
1167
|
+
isError: true
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
const pending = this.armedDialog;
|
|
1171
|
+
this.armedDialog = null;
|
|
1172
|
+
await pending;
|
|
1173
|
+
return { content: [{ type: "text", text: "Dialog handled successfully." }] };
|
|
1174
|
+
}
|
|
690
1175
|
})
|
|
691
1176
|
);
|
|
692
1177
|
}
|
|
@@ -694,33 +1179,33 @@ var BrowserTools = class {
|
|
|
694
1179
|
|
|
695
1180
|
// src/tools/notebook.ts
|
|
696
1181
|
var import_zod3 = require("zod");
|
|
697
|
-
var
|
|
1182
|
+
var import_promises3 = __toESM(require("fs/promises"));
|
|
698
1183
|
var import_child_process2 = require("child_process");
|
|
699
1184
|
var import_util2 = require("util");
|
|
700
1185
|
var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
|
|
701
1186
|
async function readNotebook(filePath) {
|
|
702
|
-
const raw = await
|
|
1187
|
+
const raw = await import_promises3.default.readFile(filePath, "utf-8");
|
|
703
1188
|
try {
|
|
704
1189
|
return JSON.parse(raw);
|
|
705
1190
|
} catch {
|
|
706
|
-
throw new Error(
|
|
1191
|
+
throw new Error(`Invalid Jupyter notebook file: ${filePath}`);
|
|
707
1192
|
}
|
|
708
1193
|
}
|
|
709
1194
|
async function writeNotebook(filePath, nb) {
|
|
710
|
-
await
|
|
1195
|
+
await import_promises3.default.writeFile(filePath, JSON.stringify(nb, null, 1), "utf-8");
|
|
711
1196
|
}
|
|
712
1197
|
var NotebookTools = class {
|
|
713
1198
|
register(server) {
|
|
714
1199
|
server.tool(
|
|
715
1200
|
"notebook_read",
|
|
716
|
-
".ipynb
|
|
717
|
-
{ path: import_zod3.z.string().describe("
|
|
1201
|
+
"Read .ipynb notebook",
|
|
1202
|
+
{ path: import_zod3.z.string().describe("Notebook file path") },
|
|
718
1203
|
async ({ path: filePath }) => {
|
|
719
1204
|
const nb = await readNotebook(filePath);
|
|
720
1205
|
const cells = nb.cells.map((cell, i) => ({
|
|
721
1206
|
index: i,
|
|
722
1207
|
type: cell.cell_type,
|
|
723
|
-
source: cell.source.join(""),
|
|
1208
|
+
source: Array.isArray(cell.source) ? cell.source.join("") : cell.source,
|
|
724
1209
|
outputs: cell.outputs?.length ?? 0
|
|
725
1210
|
}));
|
|
726
1211
|
return {
|
|
@@ -730,30 +1215,30 @@ var NotebookTools = class {
|
|
|
730
1215
|
);
|
|
731
1216
|
server.tool(
|
|
732
1217
|
"notebook_edit_cell",
|
|
733
|
-
"
|
|
1218
|
+
"Edit a specific notebook cell",
|
|
734
1219
|
{
|
|
735
1220
|
path: import_zod3.z.string(),
|
|
736
|
-
cell_index: import_zod3.z.number().describe("
|
|
737
|
-
source: import_zod3.z.string().describe("
|
|
1221
|
+
cell_index: import_zod3.z.number().describe("Cell index (0-based)"),
|
|
1222
|
+
source: import_zod3.z.string().describe("New source code")
|
|
738
1223
|
},
|
|
739
1224
|
async ({ path: filePath, cell_index, source }) => {
|
|
740
1225
|
const nb = await readNotebook(filePath);
|
|
741
1226
|
if (cell_index < 0 || cell_index >= nb.cells.length) {
|
|
742
|
-
throw new Error(
|
|
1227
|
+
throw new Error(`Invalid cell index: ${cell_index}`);
|
|
743
1228
|
}
|
|
744
1229
|
nb.cells[cell_index].source = source.split("\n").map(
|
|
745
1230
|
(l, i, arr) => i < arr.length - 1 ? l + "\n" : l
|
|
746
1231
|
);
|
|
747
1232
|
await writeNotebook(filePath, nb);
|
|
748
|
-
return { content: [{ type: "text", text: "
|
|
1233
|
+
return { content: [{ type: "text", text: "Cell updated" }] };
|
|
749
1234
|
}
|
|
750
1235
|
);
|
|
751
1236
|
server.tool(
|
|
752
1237
|
"notebook_execute",
|
|
753
|
-
"
|
|
1238
|
+
"Execute notebook (nbconvert --execute)",
|
|
754
1239
|
{
|
|
755
|
-
path: import_zod3.z.string().describe("
|
|
756
|
-
timeout: import_zod3.z.number().optional().default(300).describe("
|
|
1240
|
+
path: import_zod3.z.string().describe("Notebook file path"),
|
|
1241
|
+
timeout: import_zod3.z.number().optional().default(300).describe("Timeout per cell (seconds)")
|
|
757
1242
|
},
|
|
758
1243
|
async ({ path: filePath, timeout }) => {
|
|
759
1244
|
const nbconvertArgs = `nbconvert --to notebook --execute --inplace "${filePath}" --ExecutePreprocessor.timeout=${timeout}`;
|
|
@@ -769,7 +1254,7 @@ var NotebookTools = class {
|
|
|
769
1254
|
for (const jupyter of candidates) {
|
|
770
1255
|
try {
|
|
771
1256
|
const { stdout, stderr } = await execAsync2(`${jupyter} ${nbconvertArgs}`);
|
|
772
|
-
return { content: [{ type: "text", text: stdout || stderr || "
|
|
1257
|
+
return { content: [{ type: "text", text: stdout || stderr || "Execution complete" }] };
|
|
773
1258
|
} catch (err) {
|
|
774
1259
|
const error = err;
|
|
775
1260
|
if (error.code !== "127" && !error.message?.includes("not found") && !error.message?.includes("No such file")) {
|
|
@@ -777,17 +1262,17 @@ var NotebookTools = class {
|
|
|
777
1262
|
}
|
|
778
1263
|
}
|
|
779
1264
|
}
|
|
780
|
-
throw new Error("jupyter
|
|
1265
|
+
throw new Error("jupyter not found. Install it and try again: pip install jupyter");
|
|
781
1266
|
}
|
|
782
1267
|
);
|
|
783
1268
|
server.tool(
|
|
784
1269
|
"notebook_add_cell",
|
|
785
|
-
"
|
|
1270
|
+
"Add a new cell to notebook",
|
|
786
1271
|
{
|
|
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("
|
|
1272
|
+
path: import_zod3.z.string().describe(".ipynb file path"),
|
|
1273
|
+
cell_type: import_zod3.z.enum(["code", "markdown"]).describe("Cell type"),
|
|
1274
|
+
source: import_zod3.z.string().describe("Cell source content"),
|
|
1275
|
+
position: import_zod3.z.number().optional().describe("Insert position (0-based). Appends to end if omitted")
|
|
791
1276
|
},
|
|
792
1277
|
async ({ path: filePath, cell_type: cellType, source, position }) => {
|
|
793
1278
|
const nb = await readNotebook(filePath);
|
|
@@ -806,31 +1291,31 @@ var NotebookTools = class {
|
|
|
806
1291
|
} else if (position > nb.cells.length) {
|
|
807
1292
|
nb.cells.push(newCell);
|
|
808
1293
|
actualIndex = nb.cells.length - 1;
|
|
809
|
-
warning = ` (
|
|
1294
|
+
warning = ` (warning: position ${position} exceeded range, appended at end (index: ${actualIndex}))`;
|
|
810
1295
|
} else {
|
|
811
1296
|
const clamped = Math.max(0, position);
|
|
812
1297
|
nb.cells.splice(clamped, 0, newCell);
|
|
813
1298
|
actualIndex = clamped;
|
|
814
1299
|
}
|
|
815
1300
|
await writeNotebook(filePath, nb);
|
|
816
|
-
return { content: [{ type: "text", text:
|
|
1301
|
+
return { content: [{ type: "text", text: `Cell added (index: ${actualIndex})${warning}` }] };
|
|
817
1302
|
}
|
|
818
1303
|
);
|
|
819
1304
|
server.tool(
|
|
820
1305
|
"notebook_delete_cell",
|
|
821
|
-
"
|
|
1306
|
+
"Delete a specific notebook cell",
|
|
822
1307
|
{
|
|
823
|
-
path: import_zod3.z.string().describe(".ipynb
|
|
824
|
-
cell_index: import_zod3.z.number().describe("
|
|
1308
|
+
path: import_zod3.z.string().describe(".ipynb file path"),
|
|
1309
|
+
cell_index: import_zod3.z.number().describe("Cell index to delete (0-based)")
|
|
825
1310
|
},
|
|
826
1311
|
async ({ path: filePath, cell_index }) => {
|
|
827
1312
|
const nb = await readNotebook(filePath);
|
|
828
1313
|
if (cell_index < 0 || cell_index >= nb.cells.length) {
|
|
829
|
-
throw new Error(
|
|
1314
|
+
throw new Error(`Invalid cell index: ${cell_index}`);
|
|
830
1315
|
}
|
|
831
1316
|
nb.cells.splice(cell_index, 1);
|
|
832
1317
|
await writeNotebook(filePath, nb);
|
|
833
|
-
return { content: [{ type: "text", text:
|
|
1318
|
+
return { content: [{ type: "text", text: `Cell deleted (index: ${cell_index})` }] };
|
|
834
1319
|
}
|
|
835
1320
|
);
|
|
836
1321
|
}
|
|
@@ -850,42 +1335,9 @@ function platform() {
|
|
|
850
1335
|
}
|
|
851
1336
|
var DeviceTools = class {
|
|
852
1337
|
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
1338
|
server.tool(
|
|
887
1339
|
"camera_capture",
|
|
888
|
-
"
|
|
1340
|
+
"Camera photo capture",
|
|
889
1341
|
{
|
|
890
1342
|
output_path: import_zod4.z.string().optional()
|
|
891
1343
|
},
|
|
@@ -903,10 +1355,10 @@ var DeviceTools = class {
|
|
|
903
1355
|
} catch (err) {
|
|
904
1356
|
const e = err;
|
|
905
1357
|
return {
|
|
906
|
-
content: [{ type: "text", text: `\u274C
|
|
907
|
-
|
|
1358
|
+
content: [{ type: "text", text: `\u274C Camera not found or inaccessible.
|
|
1359
|
+
Cause: ${e.message}
|
|
908
1360
|
|
|
909
|
-
|
|
1361
|
+
Please check if a camera is connected.` }],
|
|
910
1362
|
isError: true
|
|
911
1363
|
};
|
|
912
1364
|
}
|
|
@@ -923,10 +1375,10 @@ var DeviceTools = class {
|
|
|
923
1375
|
);
|
|
924
1376
|
server.tool(
|
|
925
1377
|
"notification_send",
|
|
926
|
-
"OS
|
|
1378
|
+
"Send OS notification",
|
|
927
1379
|
{
|
|
928
|
-
title: import_zod4.z.string().describe("
|
|
929
|
-
message: import_zod4.z.string().describe("
|
|
1380
|
+
title: import_zod4.z.string().describe("Notification title"),
|
|
1381
|
+
message: import_zod4.z.string().describe("Notification body")
|
|
930
1382
|
},
|
|
931
1383
|
async ({ title, message }) => {
|
|
932
1384
|
try {
|
|
@@ -939,10 +1391,10 @@ var DeviceTools = class {
|
|
|
939
1391
|
}
|
|
940
1392
|
);
|
|
941
1393
|
});
|
|
942
|
-
return { content: [{ type: "text", text: "
|
|
1394
|
+
return { content: [{ type: "text", text: "Notification sent" }] };
|
|
943
1395
|
} catch (err) {
|
|
944
1396
|
return {
|
|
945
|
-
content: [{ type: "text", text:
|
|
1397
|
+
content: [{ type: "text", text: `Notification failed: ${err.message}` }],
|
|
946
1398
|
isError: true
|
|
947
1399
|
};
|
|
948
1400
|
}
|
|
@@ -950,7 +1402,7 @@ var DeviceTools = class {
|
|
|
950
1402
|
);
|
|
951
1403
|
server.tool(
|
|
952
1404
|
"clipboard_read",
|
|
953
|
-
"
|
|
1405
|
+
"Read clipboard",
|
|
954
1406
|
{},
|
|
955
1407
|
async () => {
|
|
956
1408
|
const p = platform();
|
|
@@ -961,7 +1413,7 @@ var DeviceTools = class {
|
|
|
961
1413
|
);
|
|
962
1414
|
server.tool(
|
|
963
1415
|
"clipboard_write",
|
|
964
|
-
"
|
|
1416
|
+
"Write to clipboard",
|
|
965
1417
|
{ text: import_zod4.z.string() },
|
|
966
1418
|
async ({ text }) => {
|
|
967
1419
|
const p = platform();
|
|
@@ -971,21 +1423,21 @@ var DeviceTools = class {
|
|
|
971
1423
|
linux: `echo "${text}" | xclip -selection clipboard`
|
|
972
1424
|
}[p];
|
|
973
1425
|
await execAsync3(cmd);
|
|
974
|
-
return { content: [{ type: "text", text: "
|
|
1426
|
+
return { content: [{ type: "text", text: "Saved to clipboard" }] };
|
|
975
1427
|
}
|
|
976
1428
|
);
|
|
977
1429
|
server.tool(
|
|
978
1430
|
"screen_record",
|
|
979
|
-
"
|
|
1431
|
+
"Start/stop screen recording (macOS: screencapture -v, others: ffmpeg)",
|
|
980
1432
|
{
|
|
981
|
-
action: import_zod4.z.enum(["start", "stop"]).describe("start:
|
|
982
|
-
output_path: import_zod4.z.string().optional().describe("
|
|
1433
|
+
action: import_zod4.z.enum(["start", "stop"]).describe("start: begin recording, stop: end recording"),
|
|
1434
|
+
output_path: import_zod4.z.string().optional().describe("Output path (used on start, default: /tmp/junis_record_<timestamp>.mp4)")
|
|
983
1435
|
},
|
|
984
1436
|
async ({ action, output_path }) => {
|
|
985
1437
|
const p = platform();
|
|
986
1438
|
if (action === "start") {
|
|
987
1439
|
if (screenRecordPid) {
|
|
988
|
-
return { content: [{ type: "text", text: "
|
|
1440
|
+
return { content: [{ type: "text", text: "Already recording." }] };
|
|
989
1441
|
}
|
|
990
1442
|
const tmpPath = output_path ?? `/tmp/junis_record_${Date.now()}.mp4`;
|
|
991
1443
|
const { spawn: spawn2 } = await import("child_process");
|
|
@@ -993,10 +1445,10 @@ var DeviceTools = class {
|
|
|
993
1445
|
const child = spawn2(cmd[0], cmd[1], { detached: true, stdio: "ignore" });
|
|
994
1446
|
child.unref();
|
|
995
1447
|
screenRecordPid = child.pid ?? null;
|
|
996
|
-
return { content: [{ type: "text", text:
|
|
1448
|
+
return { content: [{ type: "text", text: `Recording started. Output path: ${tmpPath} (PID: ${screenRecordPid})` }] };
|
|
997
1449
|
} else {
|
|
998
1450
|
if (!screenRecordPid) {
|
|
999
|
-
return { content: [{ type: "text", text: "
|
|
1451
|
+
return { content: [{ type: "text", text: "Not currently recording." }] };
|
|
1000
1452
|
}
|
|
1001
1453
|
try {
|
|
1002
1454
|
process.kill(screenRecordPid, "SIGINT");
|
|
@@ -1004,13 +1456,13 @@ var DeviceTools = class {
|
|
|
1004
1456
|
} catch {
|
|
1005
1457
|
}
|
|
1006
1458
|
screenRecordPid = null;
|
|
1007
|
-
return { content: [{ type: "text", text: "
|
|
1459
|
+
return { content: [{ type: "text", text: "Recording stopped." }] };
|
|
1008
1460
|
}
|
|
1009
1461
|
}
|
|
1010
1462
|
);
|
|
1011
1463
|
server.tool(
|
|
1012
1464
|
"location_get",
|
|
1013
|
-
"
|
|
1465
|
+
"Get current location (macOS: CoreLocation CLI, others: IP-based fallback)",
|
|
1014
1466
|
{},
|
|
1015
1467
|
async () => {
|
|
1016
1468
|
const p = platform();
|
|
@@ -1018,28 +1470,28 @@ var DeviceTools = class {
|
|
|
1018
1470
|
try {
|
|
1019
1471
|
const { stdout } = await execAsync3("CoreLocationCLI -once -format '%latitude,%longitude'", { timeout: 1e4 });
|
|
1020
1472
|
const [lat, lon] = stdout.trim().split(",");
|
|
1021
|
-
return { content: [{ type: "text", text:
|
|
1473
|
+
return { content: [{ type: "text", text: `Latitude: ${lat}, Longitude: ${lon}` }] };
|
|
1022
1474
|
} catch {
|
|
1023
1475
|
}
|
|
1024
1476
|
}
|
|
1025
1477
|
const res = await fetch("http://ip-api.com/json/");
|
|
1026
1478
|
const data = await res.json();
|
|
1027
1479
|
if (data.status !== "success") {
|
|
1028
|
-
throw new Error(`IP
|
|
1480
|
+
throw new Error(`IP location lookup failed: ${data.message ?? data.status}`);
|
|
1029
1481
|
}
|
|
1030
1482
|
return {
|
|
1031
1483
|
content: [{
|
|
1032
1484
|
type: "text",
|
|
1033
|
-
text:
|
|
1485
|
+
text: `Latitude: ${data.lat}, Longitude: ${data.lon}, City: ${data.city}, Country: ${data.country} (estimated via IP)`
|
|
1034
1486
|
}]
|
|
1035
1487
|
};
|
|
1036
1488
|
}
|
|
1037
1489
|
);
|
|
1038
1490
|
server.tool(
|
|
1039
1491
|
"audio_play",
|
|
1040
|
-
"
|
|
1492
|
+
"Play audio file (macOS: afplay, others: ffplay)",
|
|
1041
1493
|
{
|
|
1042
|
-
file_path: import_zod4.z.string().describe("
|
|
1494
|
+
file_path: import_zod4.z.string().describe("Path to the audio file to play")
|
|
1043
1495
|
},
|
|
1044
1496
|
async ({ file_path }) => {
|
|
1045
1497
|
const p = platform();
|
|
@@ -1049,7 +1501,268 @@ var DeviceTools = class {
|
|
|
1049
1501
|
linux: `ffplay -nodisp -autoexit "${file_path}"`
|
|
1050
1502
|
}[p];
|
|
1051
1503
|
await execAsync3(cmd);
|
|
1052
|
-
return { content: [{ type: "text", text:
|
|
1504
|
+
return { content: [{ type: "text", text: `Playback complete: ${file_path}` }] };
|
|
1505
|
+
}
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
// src/setup/peekaboo-installer.ts
|
|
1511
|
+
var import_child_process4 = require("child_process");
|
|
1512
|
+
var import_util4 = require("util");
|
|
1513
|
+
var import_os2 = require("os");
|
|
1514
|
+
var execFileAsync2 = (0, import_util4.promisify)(import_child_process4.execFile);
|
|
1515
|
+
async function ensurePeekaboo() {
|
|
1516
|
+
if ((0, import_os2.platform)() !== "darwin") return false;
|
|
1517
|
+
try {
|
|
1518
|
+
await execFileAsync2("which", ["peekaboo"]);
|
|
1519
|
+
return true;
|
|
1520
|
+
} catch {
|
|
1521
|
+
console.log("\u23F3 peekaboo not found, installing via brew...");
|
|
1522
|
+
try {
|
|
1523
|
+
await execFileAsync2("brew", ["tap", "steipete/tap"], { timeout: 3e4 });
|
|
1524
|
+
await execFileAsync2("brew", ["install", "peekaboo"], { timeout: 12e4 });
|
|
1525
|
+
console.log("\u2705 peekaboo installed");
|
|
1526
|
+
return true;
|
|
1527
|
+
} catch (brewErr) {
|
|
1528
|
+
console.warn("\u26A0\uFE0F peekaboo install failed:", brewErr.message);
|
|
1529
|
+
console.warn(" Desktop tools disabled. Install manually: brew tap steipete/tap && brew install peekaboo");
|
|
1530
|
+
return false;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// src/tools/desktop.ts
|
|
1536
|
+
var import_execa = require("execa");
|
|
1537
|
+
var import_zod5 = require("zod");
|
|
1538
|
+
var import_fs2 = __toESM(require("fs"));
|
|
1539
|
+
var APP_BLACKLIST = /* @__PURE__ */ new Set([
|
|
1540
|
+
"Terminal",
|
|
1541
|
+
"iTerm2",
|
|
1542
|
+
"iTerm",
|
|
1543
|
+
"Finder"
|
|
1544
|
+
// 파일 삭제 위험
|
|
1545
|
+
]);
|
|
1546
|
+
var consecutiveFailures = 0;
|
|
1547
|
+
var MAX_CONSECUTIVE_FAILURES = 2;
|
|
1548
|
+
async function peekaboo(args) {
|
|
1549
|
+
consecutiveFailures = 0;
|
|
1550
|
+
try {
|
|
1551
|
+
const { stdout } = await (0, import_execa.execa)("peekaboo", [...args, "--json-output"]);
|
|
1552
|
+
consecutiveFailures = 0;
|
|
1553
|
+
return JSON.parse(stdout);
|
|
1554
|
+
} catch (err) {
|
|
1555
|
+
consecutiveFailures++;
|
|
1556
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
1557
|
+
consecutiveFailures = 0;
|
|
1558
|
+
throw new Error(`peekaboo failed ${MAX_CONSECUTIVE_FAILURES} times in a row. Auto-stopped for safety. Last error: ${err.message}`);
|
|
1559
|
+
}
|
|
1560
|
+
throw err;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
function checkBlacklist(app) {
|
|
1564
|
+
if (app && APP_BLACKLIST.has(app)) {
|
|
1565
|
+
throw new Error(`App '${app}' is not allowed for automation (blacklisted for safety).`);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
var DesktopTools = class {
|
|
1569
|
+
register(server) {
|
|
1570
|
+
server.tool(
|
|
1571
|
+
"desktop_see",
|
|
1572
|
+
"Capture macOS Accessibility Tree snapshot. Returns structured element list with IDs for interaction. Use returned snapshotId in subsequent desktop_click/type calls for 240x speed improvement.",
|
|
1573
|
+
{
|
|
1574
|
+
app: import_zod5.z.string().optional().describe("App name to target (e.g. 'Safari', 'Finder'). Omit for frontmost app.")
|
|
1575
|
+
},
|
|
1576
|
+
async ({ app }) => {
|
|
1577
|
+
checkBlacklist(app);
|
|
1578
|
+
const args = ["see"];
|
|
1579
|
+
if (app) args.push("--app", app);
|
|
1580
|
+
const result = await peekaboo(args);
|
|
1581
|
+
const data = result.data;
|
|
1582
|
+
const snapshotId = data?.snapshot_id ?? result.snapshotId ?? result.snapshot_id;
|
|
1583
|
+
const elements = (data?.ui_elements ?? data?.elements ?? result.elements)?.map((e) => ({
|
|
1584
|
+
id: e.id,
|
|
1585
|
+
role: e.role,
|
|
1586
|
+
label: e.label,
|
|
1587
|
+
bounds: e.bounds
|
|
1588
|
+
})) ?? [];
|
|
1589
|
+
return {
|
|
1590
|
+
content: [{
|
|
1591
|
+
type: "text",
|
|
1592
|
+
text: JSON.stringify({ snapshotId, elements }, null, 2)
|
|
1593
|
+
}]
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
);
|
|
1597
|
+
server.tool(
|
|
1598
|
+
"desktop_click",
|
|
1599
|
+
"Click a UI element by label, accessibility ID, or coordinates",
|
|
1600
|
+
{
|
|
1601
|
+
on: import_zod5.z.string().describe("Element label, ID, or 'x,y' coordinates to click"),
|
|
1602
|
+
app: import_zod5.z.string().optional().describe("App name to target"),
|
|
1603
|
+
snapshot: import_zod5.z.string().optional().describe("snapshotId from desktop_see for cached interaction (faster)"),
|
|
1604
|
+
doubleClick: import_zod5.z.boolean().optional().default(false).describe("Double-click")
|
|
1605
|
+
},
|
|
1606
|
+
async ({ on, app, snapshot, doubleClick }) => {
|
|
1607
|
+
checkBlacklist(app);
|
|
1608
|
+
const args = ["click", "--on", on];
|
|
1609
|
+
if (app) args.push("--app", app);
|
|
1610
|
+
if (snapshot) args.push("--snapshot", snapshot);
|
|
1611
|
+
if (doubleClick) args.push("--double-click");
|
|
1612
|
+
const result = await peekaboo(args);
|
|
1613
|
+
return {
|
|
1614
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
);
|
|
1618
|
+
server.tool(
|
|
1619
|
+
"desktop_type",
|
|
1620
|
+
"Type text into the currently focused element",
|
|
1621
|
+
{
|
|
1622
|
+
text: import_zod5.z.string().describe("Text to type"),
|
|
1623
|
+
app: import_zod5.z.string().optional().describe("App name to target first")
|
|
1624
|
+
},
|
|
1625
|
+
async ({ text, app }) => {
|
|
1626
|
+
checkBlacklist(app);
|
|
1627
|
+
const args = ["type", text];
|
|
1628
|
+
if (app) args.push("--app", app);
|
|
1629
|
+
const result = await peekaboo(args);
|
|
1630
|
+
return {
|
|
1631
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
);
|
|
1635
|
+
server.tool(
|
|
1636
|
+
"desktop_hotkey",
|
|
1637
|
+
"Press keyboard shortcut (e.g. 'cmd,c' for copy, 'cmd,shift,t' for new tab)",
|
|
1638
|
+
{
|
|
1639
|
+
keys: import_zod5.z.string().describe("Comma-separated key combination (e.g. 'cmd,c', 'cmd,shift,t', 'escape')"),
|
|
1640
|
+
app: import_zod5.z.string().optional().describe("App name to target")
|
|
1641
|
+
},
|
|
1642
|
+
async ({ keys, app }) => {
|
|
1643
|
+
checkBlacklist(app);
|
|
1644
|
+
const args = ["hotkey", keys];
|
|
1645
|
+
if (app) args.push("--app", app);
|
|
1646
|
+
const result = await peekaboo(args);
|
|
1647
|
+
return {
|
|
1648
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
);
|
|
1652
|
+
server.tool(
|
|
1653
|
+
"desktop_scroll",
|
|
1654
|
+
"Scroll in an app or specific element",
|
|
1655
|
+
{
|
|
1656
|
+
direction: import_zod5.z.enum(["up", "down", "left", "right"]).describe("Scroll direction"),
|
|
1657
|
+
ticks: import_zod5.z.number().optional().default(3).describe("Number of scroll ticks"),
|
|
1658
|
+
on: import_zod5.z.string().optional().describe("Element label or ID to scroll within"),
|
|
1659
|
+
app: import_zod5.z.string().optional().describe("App name to target")
|
|
1660
|
+
},
|
|
1661
|
+
async ({ direction, ticks, on, app }) => {
|
|
1662
|
+
checkBlacklist(app);
|
|
1663
|
+
const args = ["scroll", "--direction", direction, "--amount", String(ticks)];
|
|
1664
|
+
if (on) args.push("--on", on);
|
|
1665
|
+
if (app) args.push("--app", app);
|
|
1666
|
+
const result = await peekaboo(args);
|
|
1667
|
+
return {
|
|
1668
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
);
|
|
1672
|
+
server.tool(
|
|
1673
|
+
"desktop_list_apps",
|
|
1674
|
+
"List all running applications on macOS",
|
|
1675
|
+
{},
|
|
1676
|
+
async () => {
|
|
1677
|
+
try {
|
|
1678
|
+
const { stdout } = await (0, import_execa.execa)("peekaboo", ["list", "apps", "--json"]);
|
|
1679
|
+
return {
|
|
1680
|
+
content: [{ type: "text", text: stdout }]
|
|
1681
|
+
};
|
|
1682
|
+
} catch (err) {
|
|
1683
|
+
consecutiveFailures++;
|
|
1684
|
+
throw err;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
);
|
|
1688
|
+
server.tool(
|
|
1689
|
+
"desktop_list_windows",
|
|
1690
|
+
"List all open windows on macOS",
|
|
1691
|
+
{
|
|
1692
|
+
app: import_zod5.z.string().optional().describe("Filter by app name (omit to query frontmost app)")
|
|
1693
|
+
},
|
|
1694
|
+
async ({ app }) => {
|
|
1695
|
+
checkBlacklist(app);
|
|
1696
|
+
try {
|
|
1697
|
+
let targetApp = app;
|
|
1698
|
+
if (!targetApp) {
|
|
1699
|
+
const { stdout: stdout2 } = await (0, import_execa.execa)("osascript", [
|
|
1700
|
+
"-e",
|
|
1701
|
+
'tell application "System Events" to get name of first application process whose frontmost is true'
|
|
1702
|
+
]);
|
|
1703
|
+
targetApp = stdout2.trim();
|
|
1704
|
+
}
|
|
1705
|
+
const args = ["list", "windows", "--app", targetApp, "--json"];
|
|
1706
|
+
const { stdout } = await (0, import_execa.execa)("peekaboo", args);
|
|
1707
|
+
return {
|
|
1708
|
+
content: [{ type: "text", text: stdout }]
|
|
1709
|
+
};
|
|
1710
|
+
} catch (err) {
|
|
1711
|
+
consecutiveFailures++;
|
|
1712
|
+
throw err;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
);
|
|
1716
|
+
server.tool(
|
|
1717
|
+
"desktop_screenshot",
|
|
1718
|
+
"Take macOS screen screenshot using Peekaboo (Retina support, better quality than screen_capture)",
|
|
1719
|
+
{
|
|
1720
|
+
app: import_zod5.z.string().optional().describe("Capture specific app window"),
|
|
1721
|
+
mode: import_zod5.z.enum(["screen", "window"]).optional().default("screen").describe("Capture mode")
|
|
1722
|
+
},
|
|
1723
|
+
async ({ app, mode }) => {
|
|
1724
|
+
checkBlacklist(app);
|
|
1725
|
+
const args = ["image", "--mode", mode];
|
|
1726
|
+
if (app) args.push("--app", app);
|
|
1727
|
+
const result = await peekaboo(args);
|
|
1728
|
+
const data = result.data;
|
|
1729
|
+
const files = data?.files;
|
|
1730
|
+
const filePath = files?.[0]?.path;
|
|
1731
|
+
if (filePath) {
|
|
1732
|
+
const imageBuffer = import_fs2.default.readFileSync(filePath);
|
|
1733
|
+
return {
|
|
1734
|
+
content: [{
|
|
1735
|
+
type: "image",
|
|
1736
|
+
data: imageBuffer.toString("base64"),
|
|
1737
|
+
mimeType: "image/png"
|
|
1738
|
+
}]
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
return {
|
|
1742
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
);
|
|
1746
|
+
server.tool(
|
|
1747
|
+
"desktop_menu",
|
|
1748
|
+
"Click menu bar item (e.g. 'File > New Tab')",
|
|
1749
|
+
{
|
|
1750
|
+
path: import_zod5.z.array(import_zod5.z.string()).describe("Menu path as array (e.g. ['File', 'New Tab'])"),
|
|
1751
|
+
app: import_zod5.z.string().optional().describe("App name to target")
|
|
1752
|
+
},
|
|
1753
|
+
async ({ path: path4, app }) => {
|
|
1754
|
+
checkBlacklist(app);
|
|
1755
|
+
const args = ["menu", "click", "--path", path4.join(" > ")];
|
|
1756
|
+
if (app) args.push("--app", app);
|
|
1757
|
+
try {
|
|
1758
|
+
const { stdout } = await (0, import_execa.execa)("peekaboo", args);
|
|
1759
|
+
return {
|
|
1760
|
+
content: [{ type: "text", text: stdout || "Menu click executed" }]
|
|
1761
|
+
};
|
|
1762
|
+
} catch (err) {
|
|
1763
|
+
consecutiveFailures++;
|
|
1764
|
+
throw err;
|
|
1765
|
+
}
|
|
1053
1766
|
}
|
|
1054
1767
|
);
|
|
1055
1768
|
}
|
|
@@ -1058,6 +1771,7 @@ var DeviceTools = class {
|
|
|
1058
1771
|
// src/server/mcp.ts
|
|
1059
1772
|
var mcpPort = 3e3;
|
|
1060
1773
|
var globalBrowserTools = null;
|
|
1774
|
+
var desktopToolsEnabled = false;
|
|
1061
1775
|
function createMcpServer() {
|
|
1062
1776
|
const server = new import_mcp.McpServer({
|
|
1063
1777
|
name: "junis",
|
|
@@ -1072,6 +1786,10 @@ function createMcpServer() {
|
|
|
1072
1786
|
notebookTools.register(server);
|
|
1073
1787
|
const deviceTools = new DeviceTools();
|
|
1074
1788
|
deviceTools.register(server);
|
|
1789
|
+
if (desktopToolsEnabled) {
|
|
1790
|
+
const desktopTools = new DesktopTools();
|
|
1791
|
+
desktopTools.register(server);
|
|
1792
|
+
}
|
|
1075
1793
|
return server;
|
|
1076
1794
|
}
|
|
1077
1795
|
function readBody(req) {
|
|
@@ -1180,6 +1898,10 @@ function handleOAuthDiscovery(req, res, port) {
|
|
|
1180
1898
|
async function startMCPServer(port) {
|
|
1181
1899
|
globalBrowserTools = new BrowserTools();
|
|
1182
1900
|
await globalBrowserTools.init();
|
|
1901
|
+
desktopToolsEnabled = await ensurePeekaboo();
|
|
1902
|
+
if (desktopToolsEnabled) {
|
|
1903
|
+
console.log("\u2705 Peekaboo available \u2014 desktop tools enabled");
|
|
1904
|
+
}
|
|
1183
1905
|
let resolvedPort = port;
|
|
1184
1906
|
const httpServer = (0, import_http.createServer)(
|
|
1185
1907
|
async (req, res) => {
|
|
@@ -1303,49 +2025,74 @@ async function handleMCPRequest(id, payload) {
|
|
|
1303
2025
|
return null;
|
|
1304
2026
|
}
|
|
1305
2027
|
|
|
2028
|
+
// src/server/stdio.ts
|
|
2029
|
+
var import_mcp2 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
2030
|
+
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
2031
|
+
async function startStdioServer() {
|
|
2032
|
+
const server = new import_mcp2.McpServer({ name: "junis", version: "0.1.0" });
|
|
2033
|
+
const fsTools = new FilesystemTools();
|
|
2034
|
+
fsTools.register(server);
|
|
2035
|
+
const browserTools = new BrowserTools();
|
|
2036
|
+
await browserTools.init();
|
|
2037
|
+
browserTools.register(server);
|
|
2038
|
+
const notebookTools = new NotebookTools();
|
|
2039
|
+
notebookTools.register(server);
|
|
2040
|
+
const deviceTools = new DeviceTools();
|
|
2041
|
+
deviceTools.register(server);
|
|
2042
|
+
const transport = new import_stdio.StdioServerTransport();
|
|
2043
|
+
await server.connect(transport);
|
|
2044
|
+
process.on("SIGINT", async () => {
|
|
2045
|
+
await browserTools.cleanup();
|
|
2046
|
+
process.exit(0);
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
if (require.main === module) {
|
|
2050
|
+
startStdioServer().catch(console.error);
|
|
2051
|
+
}
|
|
2052
|
+
|
|
1306
2053
|
// src/cli/daemon.ts
|
|
1307
|
-
var
|
|
2054
|
+
var import_fs3 = __toESM(require("fs"));
|
|
1308
2055
|
var import_path3 = __toESM(require("path"));
|
|
1309
|
-
var
|
|
1310
|
-
var
|
|
1311
|
-
var CONFIG_DIR2 = import_path3.default.join(
|
|
2056
|
+
var import_os3 = __toESM(require("os"));
|
|
2057
|
+
var import_child_process5 = require("child_process");
|
|
2058
|
+
var CONFIG_DIR2 = import_path3.default.join(import_os3.default.homedir(), ".junis");
|
|
1312
2059
|
var PID_FILE = import_path3.default.join(CONFIG_DIR2, "junis.pid");
|
|
1313
2060
|
var LOG_DIR = import_path3.default.join(CONFIG_DIR2, "logs");
|
|
1314
2061
|
var LOG_FILE = import_path3.default.join(LOG_DIR, "junis.log");
|
|
1315
2062
|
var PLIST_PATH = import_path3.default.join(
|
|
1316
|
-
|
|
2063
|
+
import_os3.default.homedir(),
|
|
1317
2064
|
"Library/LaunchAgents/ai.junis.plist"
|
|
1318
2065
|
);
|
|
1319
2066
|
var SYSTEMD_PATH = import_path3.default.join(
|
|
1320
|
-
|
|
2067
|
+
import_os3.default.homedir(),
|
|
1321
2068
|
".config/systemd/user/junis.service"
|
|
1322
2069
|
);
|
|
1323
2070
|
function isRunning() {
|
|
1324
2071
|
try {
|
|
1325
|
-
if (!
|
|
1326
|
-
const pid = parseInt(
|
|
2072
|
+
if (!import_fs3.default.existsSync(PID_FILE)) return { running: false };
|
|
2073
|
+
const pid = parseInt(import_fs3.default.readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
1327
2074
|
if (isNaN(pid)) return { running: false };
|
|
1328
2075
|
process.kill(pid, 0);
|
|
1329
2076
|
return { running: true, pid };
|
|
1330
2077
|
} catch {
|
|
1331
2078
|
try {
|
|
1332
|
-
|
|
2079
|
+
import_fs3.default.unlinkSync(PID_FILE);
|
|
1333
2080
|
} catch {
|
|
1334
2081
|
}
|
|
1335
2082
|
return { running: false };
|
|
1336
2083
|
}
|
|
1337
2084
|
}
|
|
1338
2085
|
function writePid(pid) {
|
|
1339
|
-
|
|
1340
|
-
|
|
2086
|
+
import_fs3.default.mkdirSync(CONFIG_DIR2, { recursive: true });
|
|
2087
|
+
import_fs3.default.writeFileSync(PID_FILE, String(pid), "utf-8");
|
|
1341
2088
|
}
|
|
1342
2089
|
function startDaemon(port) {
|
|
1343
|
-
|
|
2090
|
+
import_fs3.default.mkdirSync(LOG_DIR, { recursive: true });
|
|
1344
2091
|
const nodePath = process.execPath;
|
|
1345
2092
|
const scriptPath = process.argv[1];
|
|
1346
|
-
const out =
|
|
1347
|
-
const err =
|
|
1348
|
-
const child = (0,
|
|
2093
|
+
const out = import_fs3.default.openSync(LOG_FILE, "a");
|
|
2094
|
+
const err = import_fs3.default.openSync(LOG_FILE, "a");
|
|
2095
|
+
const child = (0, import_child_process5.spawn)(nodePath, [scriptPath, "start", "--daemon", "--port", String(port)], {
|
|
1349
2096
|
detached: true,
|
|
1350
2097
|
stdio: ["ignore", out, err],
|
|
1351
2098
|
env: { ...process.env }
|
|
@@ -1361,7 +2108,7 @@ function stopDaemon() {
|
|
|
1361
2108
|
try {
|
|
1362
2109
|
process.kill(pid, "SIGTERM");
|
|
1363
2110
|
try {
|
|
1364
|
-
|
|
2111
|
+
import_fs3.default.unlinkSync(PID_FILE);
|
|
1365
2112
|
} catch {
|
|
1366
2113
|
}
|
|
1367
2114
|
return true;
|
|
@@ -1395,7 +2142,7 @@ var ServiceManager = class {
|
|
|
1395
2142
|
<key>EnvironmentVariables</key>
|
|
1396
2143
|
<dict>
|
|
1397
2144
|
<key>HOME</key>
|
|
1398
|
-
<string>${
|
|
2145
|
+
<string>${import_os3.default.homedir()}</string>
|
|
1399
2146
|
<key>PATH</key>
|
|
1400
2147
|
<string>${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}</string>
|
|
1401
2148
|
${process.env.JUNIS_API_URL ? `<key>JUNIS_API_URL</key>
|
|
@@ -1415,14 +2162,14 @@ var ServiceManager = class {
|
|
|
1415
2162
|
<string>${LOG_FILE}</string>
|
|
1416
2163
|
</dict>
|
|
1417
2164
|
</plist>`;
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
2165
|
+
import_fs3.default.mkdirSync(import_path3.default.dirname(PLIST_PATH), { recursive: true });
|
|
2166
|
+
import_fs3.default.mkdirSync(LOG_DIR, { recursive: true });
|
|
2167
|
+
import_fs3.default.writeFileSync(PLIST_PATH, plist, "utf-8");
|
|
1421
2168
|
try {
|
|
1422
|
-
(0,
|
|
1423
|
-
(0,
|
|
2169
|
+
(0, import_child_process5.execSync)(`launchctl unload "${PLIST_PATH}" 2>/dev/null || true`);
|
|
2170
|
+
(0, import_child_process5.execSync)(`launchctl load "${PLIST_PATH}"`);
|
|
1424
2171
|
} catch (e) {
|
|
1425
|
-
throw new Error(`launchctl load
|
|
2172
|
+
throw new Error(`launchctl load failed: ${e.message}`);
|
|
1426
2173
|
}
|
|
1427
2174
|
} else if (this.platform === "linux") {
|
|
1428
2175
|
const unit = `[Unit]
|
|
@@ -1433,7 +2180,7 @@ After=network.target
|
|
|
1433
2180
|
ExecStart=${nodePath} ${scriptPath} start --daemon
|
|
1434
2181
|
Restart=always
|
|
1435
2182
|
RestartSec=5
|
|
1436
|
-
Environment=HOME=${
|
|
2183
|
+
Environment=HOME=${import_os3.default.homedir()}
|
|
1437
2184
|
Environment=PATH=${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}
|
|
1438
2185
|
${process.env.JUNIS_API_URL ? `Environment=JUNIS_API_URL=${process.env.JUNIS_API_URL}` : ""}
|
|
1439
2186
|
${process.env.JUNIS_WS_URL ? `Environment=JUNIS_WS_URL=${process.env.JUNIS_WS_URL}` : ""}
|
|
@@ -1443,14 +2190,14 @@ StandardError=append:${LOG_FILE}
|
|
|
1443
2190
|
|
|
1444
2191
|
[Install]
|
|
1445
2192
|
WantedBy=default.target`;
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
(0,
|
|
1450
|
-
(0,
|
|
1451
|
-
(0,
|
|
2193
|
+
import_fs3.default.mkdirSync(import_path3.default.dirname(SYSTEMD_PATH), { recursive: true });
|
|
2194
|
+
import_fs3.default.mkdirSync(LOG_DIR, { recursive: true });
|
|
2195
|
+
import_fs3.default.writeFileSync(SYSTEMD_PATH, unit, "utf-8");
|
|
2196
|
+
(0, import_child_process5.execSync)("systemctl --user daemon-reload");
|
|
2197
|
+
(0, import_child_process5.execSync)("systemctl --user enable junis");
|
|
2198
|
+
(0, import_child_process5.execSync)("systemctl --user start junis");
|
|
1452
2199
|
} else {
|
|
1453
|
-
(0,
|
|
2200
|
+
(0, import_child_process5.execSync)(
|
|
1454
2201
|
`schtasks /Create /F /TN "Junis" /TR "${nodePath} ${scriptPath} start --daemon" /SC ONLOGON /RL HIGHEST`
|
|
1455
2202
|
);
|
|
1456
2203
|
}
|
|
@@ -1458,21 +2205,21 @@ WantedBy=default.target`;
|
|
|
1458
2205
|
async uninstall() {
|
|
1459
2206
|
if (this.platform === "mac") {
|
|
1460
2207
|
try {
|
|
1461
|
-
(0,
|
|
1462
|
-
if (
|
|
2208
|
+
(0, import_child_process5.execSync)(`launchctl unload "${PLIST_PATH}" 2>/dev/null || true`);
|
|
2209
|
+
if (import_fs3.default.existsSync(PLIST_PATH)) import_fs3.default.unlinkSync(PLIST_PATH);
|
|
1463
2210
|
} catch {
|
|
1464
2211
|
}
|
|
1465
2212
|
} else if (this.platform === "linux") {
|
|
1466
2213
|
try {
|
|
1467
|
-
(0,
|
|
1468
|
-
(0,
|
|
1469
|
-
if (
|
|
1470
|
-
(0,
|
|
2214
|
+
(0, import_child_process5.execSync)("systemctl --user stop junis 2>/dev/null || true");
|
|
2215
|
+
(0, import_child_process5.execSync)("systemctl --user disable junis 2>/dev/null || true");
|
|
2216
|
+
if (import_fs3.default.existsSync(SYSTEMD_PATH)) import_fs3.default.unlinkSync(SYSTEMD_PATH);
|
|
2217
|
+
(0, import_child_process5.execSync)("systemctl --user daemon-reload 2>/dev/null || true");
|
|
1471
2218
|
} catch {
|
|
1472
2219
|
}
|
|
1473
2220
|
} else {
|
|
1474
2221
|
try {
|
|
1475
|
-
(0,
|
|
2222
|
+
(0, import_child_process5.execSync)('schtasks /Delete /F /TN "Junis" 2>nul || true');
|
|
1476
2223
|
} catch {
|
|
1477
2224
|
}
|
|
1478
2225
|
}
|
|
@@ -1481,10 +2228,10 @@ WantedBy=default.target`;
|
|
|
1481
2228
|
|
|
1482
2229
|
// src/cli/index.ts
|
|
1483
2230
|
var { version } = require_package();
|
|
1484
|
-
import_commander.program.name("junis").description("
|
|
2231
|
+
import_commander.program.name("junis").description("MCP server for full device control by AI").version(version);
|
|
1485
2232
|
function getSystemInfo() {
|
|
1486
|
-
const
|
|
1487
|
-
if (
|
|
2233
|
+
const platform3 = process.platform;
|
|
2234
|
+
if (platform3 === "darwin") {
|
|
1488
2235
|
try {
|
|
1489
2236
|
const { execSync: execSync2 } = require("child_process");
|
|
1490
2237
|
const sw = execSync2("sw_vers -productVersion", { encoding: "utf8" }).trim();
|
|
@@ -1494,13 +2241,13 @@ function getSystemInfo() {
|
|
|
1494
2241
|
return "macOS";
|
|
1495
2242
|
}
|
|
1496
2243
|
}
|
|
1497
|
-
if (
|
|
2244
|
+
if (platform3 === "win32") return "Windows";
|
|
1498
2245
|
return "Linux";
|
|
1499
2246
|
}
|
|
1500
2247
|
function getDeviceName() {
|
|
1501
|
-
const
|
|
1502
|
-
if (
|
|
1503
|
-
if (
|
|
2248
|
+
const platform3 = process.platform;
|
|
2249
|
+
if (platform3 === "darwin") return "Mac";
|
|
2250
|
+
if (platform3 === "win32") return "Windows PC";
|
|
1504
2251
|
return "Linux PC";
|
|
1505
2252
|
}
|
|
1506
2253
|
function printBanner() {
|
|
@@ -1539,24 +2286,27 @@ async function runForeground(config, port) {
|
|
|
1539
2286
|
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
2287
|
console.log(` \u25C9 MCP server started on port ${actualPort}`);
|
|
1541
2288
|
const relay = new RelayClient(config, handleMCPRequest, async () => {
|
|
1542
|
-
console.log("[junis]
|
|
2289
|
+
console.log("[junis] Session expired - re-authentication required");
|
|
1543
2290
|
try {
|
|
1544
2291
|
let waitingPrinted = false;
|
|
1545
2292
|
const authResult = await authenticate(
|
|
1546
2293
|
deviceName,
|
|
1547
2294
|
platformName,
|
|
1548
2295
|
(uri) => {
|
|
1549
|
-
console.log(`[junis]
|
|
2296
|
+
console.log(`[junis] Browser re-auth: ${uri}`);
|
|
1550
2297
|
},
|
|
1551
2298
|
() => {
|
|
1552
2299
|
if (!waitingPrinted) waitingPrinted = true;
|
|
1553
|
-
}
|
|
2300
|
+
},
|
|
2301
|
+
config.device_key,
|
|
2302
|
+
config.token,
|
|
2303
|
+
config.device_key
|
|
1554
2304
|
);
|
|
1555
2305
|
config.token = authResult.token;
|
|
1556
2306
|
saveConfig(config);
|
|
1557
2307
|
relay.restart();
|
|
1558
2308
|
} catch (e) {
|
|
1559
|
-
console.error("[junis]
|
|
2309
|
+
console.error("[junis] Re-authentication failed:", e);
|
|
1560
2310
|
process.exit(1);
|
|
1561
2311
|
}
|
|
1562
2312
|
});
|
|
@@ -1565,8 +2315,8 @@ async function runForeground(config, port) {
|
|
|
1565
2315
|
console.log(" \u25C9 Relay connected");
|
|
1566
2316
|
console.log("");
|
|
1567
2317
|
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
|
|
2318
|
+
console.log(" \u2705 ALL SET \u2014 Junis is running in the foreground.");
|
|
2319
|
+
console.log(" Press Ctrl+C to stop.");
|
|
1570
2320
|
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
2321
|
console.log("");
|
|
1572
2322
|
console.log(` \u2192 ${webUrl}`);
|
|
@@ -1590,28 +2340,33 @@ async function runBackground(config, port) {
|
|
|
1590
2340
|
console.log(" \u25C9 Service registered ........... \u2705");
|
|
1591
2341
|
console.log(" \u25C9 Auto-start on boot ........... \u2705");
|
|
1592
2342
|
} catch (e) {
|
|
1593
|
-
console.warn(` \u26A0\uFE0F
|
|
1594
|
-
console.warn("
|
|
2343
|
+
console.warn(` \u26A0\uFE0F Service registration failed: ${e.message}`);
|
|
2344
|
+
console.warn(" Running as background process only.");
|
|
1595
2345
|
startDaemon(port);
|
|
1596
2346
|
}
|
|
1597
2347
|
const webUrl = process.env.JUNIS_WEB_URL ?? "https://junis.ai";
|
|
1598
2348
|
console.log("");
|
|
1599
2349
|
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
|
-
console.log("
|
|
2350
|
+
console.log(" \u2705 ALL SET \u2014 Junis is running in the background.");
|
|
2351
|
+
console.log(" Auto-starts on boot.");
|
|
1602
2352
|
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
2353
|
console.log("");
|
|
1604
2354
|
console.log(` \u2192 ${webUrl}`);
|
|
1605
2355
|
console.log("");
|
|
1606
|
-
console.log("
|
|
2356
|
+
console.log(" To stop: npx junis stop");
|
|
1607
2357
|
console.log("");
|
|
1608
2358
|
process.exit(0);
|
|
1609
2359
|
}
|
|
1610
|
-
import_commander.program.command("start", { isDefault: true }).description("Junis
|
|
2360
|
+
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
2361
|
const port = parseInt(options.port, 10);
|
|
2362
|
+
if (options.stdio) {
|
|
2363
|
+
await startStdioServer();
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
1612
2366
|
if (options.foreground) {
|
|
1613
2367
|
printBanner();
|
|
1614
|
-
|
|
2368
|
+
const existingConfig2 = loadConfig();
|
|
2369
|
+
let config2 = options.reset ? null : existingConfig2;
|
|
1615
2370
|
const deviceName2 = config2?.device_name ?? `${process.env["USER"] ?? "user"}'s ${getDeviceName()}`;
|
|
1616
2371
|
const platformName2 = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux";
|
|
1617
2372
|
printStep1(port);
|
|
@@ -1634,7 +2389,10 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1634
2389
|
} else {
|
|
1635
2390
|
process.stdout.write("\xB7");
|
|
1636
2391
|
}
|
|
1637
|
-
}
|
|
2392
|
+
},
|
|
2393
|
+
existingConfig2?.device_key,
|
|
2394
|
+
existingConfig2?.token,
|
|
2395
|
+
existingConfig2?.device_key
|
|
1638
2396
|
);
|
|
1639
2397
|
console.log("");
|
|
1640
2398
|
console.log(` \u2705 Authenticated as ${authResult.email ?? "your account"}`);
|
|
@@ -1684,9 +2442,10 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1684
2442
|
await startMCPServer(port);
|
|
1685
2443
|
return;
|
|
1686
2444
|
}
|
|
1687
|
-
|
|
2445
|
+
const existingConfig2 = loadConfig();
|
|
2446
|
+
let config2 = options.reset ? null : existingConfig2;
|
|
1688
2447
|
if (!config2) {
|
|
1689
|
-
console.error("\u274C
|
|
2448
|
+
console.error("\u274C No credentials found. Run npx junis first.");
|
|
1690
2449
|
process.exit(1);
|
|
1691
2450
|
}
|
|
1692
2451
|
const deviceName2 = config2.device_name;
|
|
@@ -1694,24 +2453,27 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1694
2453
|
const actualPort = await startMCPServer(port);
|
|
1695
2454
|
console.log(`[junis daemon] MCP server started on port ${actualPort}`);
|
|
1696
2455
|
const relay = new RelayClient(config2, handleMCPRequest, async () => {
|
|
1697
|
-
console.log("[junis daemon]
|
|
2456
|
+
console.log("[junis daemon] Session expired - re-authentication required");
|
|
1698
2457
|
try {
|
|
1699
2458
|
let waitingPrinted = false;
|
|
1700
2459
|
const authResult = await authenticate(
|
|
1701
2460
|
deviceName2,
|
|
1702
2461
|
platformName2,
|
|
1703
2462
|
(uri) => {
|
|
1704
|
-
console.log(`[junis daemon]
|
|
2463
|
+
console.log(`[junis daemon] Browser re-auth: ${uri}`);
|
|
1705
2464
|
},
|
|
1706
2465
|
() => {
|
|
1707
2466
|
if (!waitingPrinted) waitingPrinted = true;
|
|
1708
|
-
}
|
|
2467
|
+
},
|
|
2468
|
+
config2.device_key,
|
|
2469
|
+
config2.token,
|
|
2470
|
+
config2.device_key
|
|
1709
2471
|
);
|
|
1710
2472
|
config2.token = authResult.token;
|
|
1711
2473
|
saveConfig(config2);
|
|
1712
2474
|
relay.restart();
|
|
1713
2475
|
} catch (e) {
|
|
1714
|
-
console.error("[junis daemon]
|
|
2476
|
+
console.error("[junis daemon] Re-authentication failed:", e);
|
|
1715
2477
|
process.exit(1);
|
|
1716
2478
|
}
|
|
1717
2479
|
});
|
|
@@ -1731,11 +2493,27 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1731
2493
|
printBanner();
|
|
1732
2494
|
const { running, pid } = isRunning();
|
|
1733
2495
|
if (running) {
|
|
1734
|
-
console.log(`\u2705 Junis
|
|
1735
|
-
console.log("
|
|
2496
|
+
console.log(`\u2705 Junis is running. (PID: ${pid})`);
|
|
2497
|
+
console.log(" To stop: npx junis stop");
|
|
1736
2498
|
return;
|
|
1737
2499
|
}
|
|
1738
|
-
|
|
2500
|
+
const mode = await (0, import_prompts.select)({
|
|
2501
|
+
message: "Select run mode:",
|
|
2502
|
+
choices: [
|
|
2503
|
+
{
|
|
2504
|
+
name: "Foreground",
|
|
2505
|
+
value: "foreground",
|
|
2506
|
+
description: "Runs in the current terminal. Press Ctrl+C to stop.\n Full OS access: camera, notifications, and more."
|
|
2507
|
+
},
|
|
2508
|
+
{
|
|
2509
|
+
name: "Background (daemon)",
|
|
2510
|
+
value: "background",
|
|
2511
|
+
description: "Runs as a background service. Stays alive after\n closing the terminal. Auto-starts on reboot."
|
|
2512
|
+
}
|
|
2513
|
+
]
|
|
2514
|
+
});
|
|
2515
|
+
const existingConfig = loadConfig();
|
|
2516
|
+
let config = options.reset ? null : existingConfig;
|
|
1739
2517
|
const deviceName = config?.device_name ?? `${process.env["USER"] ?? "user"}'s ${getDeviceName()}`;
|
|
1740
2518
|
const platformName = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux";
|
|
1741
2519
|
printStep1(port);
|
|
@@ -1758,7 +2536,10 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1758
2536
|
} else {
|
|
1759
2537
|
process.stdout.write("\xB7");
|
|
1760
2538
|
}
|
|
1761
|
-
}
|
|
2539
|
+
},
|
|
2540
|
+
existingConfig?.device_key,
|
|
2541
|
+
existingConfig?.token,
|
|
2542
|
+
existingConfig?.device_key
|
|
1762
2543
|
);
|
|
1763
2544
|
console.log("");
|
|
1764
2545
|
console.log(` \u2705 Authenticated as ${authResult.email ?? "your account"}`);
|
|
@@ -1799,28 +2580,13 @@ import_commander.program.command("start", { isDefault: true }).description("Juni
|
|
|
1799
2580
|
console.log(" \u25C9 Status ....................... \u{1F7E2} online");
|
|
1800
2581
|
console.log("");
|
|
1801
2582
|
}
|
|
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
2583
|
if (mode === "foreground") {
|
|
1818
2584
|
await runForeground(config, port);
|
|
1819
2585
|
} else {
|
|
1820
2586
|
await runBackground(config, port);
|
|
1821
2587
|
}
|
|
1822
2588
|
});
|
|
1823
|
-
import_commander.program.command("stop").description("
|
|
2589
|
+
import_commander.program.command("stop").description("Stop background service and disable auto-start").action(async () => {
|
|
1824
2590
|
const stopped = stopDaemon();
|
|
1825
2591
|
const svc = new ServiceManager();
|
|
1826
2592
|
let serviceUninstalled = false;
|
|
@@ -1830,29 +2596,47 @@ import_commander.program.command("stop").description("\uBC31\uADF8\uB77C\uC6B4\u
|
|
|
1830
2596
|
} catch {
|
|
1831
2597
|
}
|
|
1832
2598
|
if (stopped || serviceUninstalled) {
|
|
1833
|
-
console.log("\u2705 Junis
|
|
1834
|
-
console.log("
|
|
2599
|
+
console.log("\u2705 Junis service has been stopped.");
|
|
2600
|
+
console.log(" Auto-start has been disabled.");
|
|
1835
2601
|
} else {
|
|
1836
|
-
console.log("\u2139\uFE0F
|
|
2602
|
+
console.log("\u2139\uFE0F No running Junis process found.");
|
|
1837
2603
|
}
|
|
1838
2604
|
});
|
|
1839
|
-
import_commander.program.command("logout").description("
|
|
2605
|
+
import_commander.program.command("logout").description("Clear authentication credentials").action(() => {
|
|
1840
2606
|
clearConfig();
|
|
1841
|
-
console.log("\u2705
|
|
2607
|
+
console.log("\u2705 Authentication credentials cleared");
|
|
1842
2608
|
});
|
|
1843
|
-
import_commander.program.command("status").description("
|
|
2609
|
+
import_commander.program.command("status").description("Check current status").action(() => {
|
|
1844
2610
|
const config = loadConfig();
|
|
1845
2611
|
const { running, pid } = isRunning();
|
|
1846
2612
|
if (!config) {
|
|
1847
|
-
console.log("\u274C
|
|
2613
|
+
console.log("\u274C Not authenticated (run npx junis)");
|
|
1848
2614
|
} else if (running) {
|
|
1849
|
-
console.log(`\u2705
|
|
1850
|
-
console.log(`
|
|
1851
|
-
console.log(`
|
|
2615
|
+
console.log(`\u2705 Running (PID: ${pid})`);
|
|
2616
|
+
console.log(` Device: ${config.device_name}`);
|
|
2617
|
+
console.log(` Registered: ${config.created_at}`);
|
|
1852
2618
|
} else {
|
|
1853
|
-
console.log("\u26A0\uFE0F
|
|
1854
|
-
console.log(`
|
|
1855
|
-
console.log("
|
|
2619
|
+
console.log("\u26A0\uFE0F Authenticated, service stopped");
|
|
2620
|
+
console.log(` Device: ${config.device_name}`);
|
|
2621
|
+
console.log(" To start: npx junis");
|
|
1856
2622
|
}
|
|
1857
2623
|
});
|
|
2624
|
+
import_commander.program.addHelpText("after", `
|
|
2625
|
+
Examples:
|
|
2626
|
+
npx junis Interactive mode (foreground/background)
|
|
2627
|
+
npx junis --local Local MCP server only (no cloud)
|
|
2628
|
+
npx junis --stdio Run as stdio MCP server for Claude Code, etc.
|
|
2629
|
+
npx junis stop Stop background service
|
|
2630
|
+
npx junis status Check current status
|
|
2631
|
+
|
|
2632
|
+
MCP Client Config (Claude Code, Claude Desktop, Codex, etc.):
|
|
2633
|
+
{
|
|
2634
|
+
"mcpServers": {
|
|
2635
|
+
"junis": {
|
|
2636
|
+
"command": "npx",
|
|
2637
|
+
"args": ["-y", "junis", "--stdio"]
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
`);
|
|
1858
2642
|
import_commander.program.parse();
|