junis 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +395 -330
- package/dist/server/mcp.js +182 -220
- package/dist/server/stdio.js +150 -183
- package/package.json +3 -2
package/dist/cli/index.js
CHANGED
|
@@ -1,92 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
var __create = Object.create;
|
|
4
|
-
var __defProp = Object.defineProperty;
|
|
5
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
-
var __commonJS = (cb, mod) => function __require() {
|
|
10
|
-
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
11
|
-
};
|
|
12
|
-
var __copyProps = (to, from, except, desc) => {
|
|
13
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
-
for (let key of __getOwnPropNames(from))
|
|
15
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
-
}
|
|
18
|
-
return to;
|
|
19
|
-
};
|
|
20
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
-
mod
|
|
27
|
-
));
|
|
28
|
-
|
|
29
|
-
// package.json
|
|
30
|
-
var require_package = __commonJS({
|
|
31
|
-
"package.json"(exports2, module2) {
|
|
32
|
-
module2.exports = {
|
|
33
|
-
name: "junis",
|
|
34
|
-
version: "0.3.4",
|
|
35
|
-
description: "One-line device control for AI agents",
|
|
36
|
-
bin: {
|
|
37
|
-
junis: "dist/cli/index.js"
|
|
38
|
-
},
|
|
39
|
-
scripts: {
|
|
40
|
-
build: "tsup src/cli/index.ts --format cjs --dts --out-dir dist/cli && tsup src/server/mcp.ts --format cjs --out-dir dist/server && tsup src/server/stdio.ts --format cjs --out-dir dist/server",
|
|
41
|
-
dev: "tsx src/cli/index.ts",
|
|
42
|
-
"type-check": "tsc --noEmit",
|
|
43
|
-
prepublishOnly: "npm run build"
|
|
44
|
-
},
|
|
45
|
-
dependencies: {
|
|
46
|
-
"@inquirer/prompts": "^8.2.1",
|
|
47
|
-
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
48
|
-
browserclaw: "^0.2.7",
|
|
49
|
-
commander: "^12.0.0",
|
|
50
|
-
execa: "^8.0.0",
|
|
51
|
-
glob: "^11.0.0",
|
|
52
|
-
"node-notifier": "^10.0.1",
|
|
53
|
-
open: "^10.1.0",
|
|
54
|
-
"playwright-core": ">=1.50.0",
|
|
55
|
-
ws: "^8.18.0",
|
|
56
|
-
zod: "^4.3.6"
|
|
57
|
-
},
|
|
58
|
-
devDependencies: {
|
|
59
|
-
"@types/node": "^20.0.0",
|
|
60
|
-
"@types/node-notifier": "^8.0.5",
|
|
61
|
-
"@types/ws": "^8.5.0",
|
|
62
|
-
tsup: "^8.0.0",
|
|
63
|
-
tsx: "^4.0.0",
|
|
64
|
-
typescript: "^5.0.0"
|
|
65
|
-
},
|
|
66
|
-
engines: {
|
|
67
|
-
node: ">=18.0.0"
|
|
68
|
-
},
|
|
69
|
-
files: [
|
|
70
|
-
"dist/"
|
|
71
|
-
]
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
2
|
|
|
76
3
|
// src/cli/index.ts
|
|
77
|
-
|
|
78
|
-
|
|
4
|
+
import { program } from "commander";
|
|
5
|
+
import { select } from "@inquirer/prompts";
|
|
79
6
|
|
|
80
7
|
// src/cli/config.ts
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
var CONFIG_DIR =
|
|
85
|
-
var CONFIG_FILE =
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import os from "os";
|
|
11
|
+
var CONFIG_DIR = path.join(os.homedir(), ".junis");
|
|
12
|
+
var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
86
13
|
function loadConfig() {
|
|
87
14
|
try {
|
|
88
|
-
if (!
|
|
89
|
-
const raw =
|
|
15
|
+
if (!fs.existsSync(CONFIG_FILE)) return null;
|
|
16
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
90
17
|
return JSON.parse(raw);
|
|
91
18
|
} catch {
|
|
92
19
|
return null;
|
|
@@ -94,9 +21,9 @@ function loadConfig() {
|
|
|
94
21
|
}
|
|
95
22
|
function saveConfig(config) {
|
|
96
23
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
24
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
25
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
26
|
+
fs.chmodSync(CONFIG_FILE, 384);
|
|
100
27
|
} catch (err) {
|
|
101
28
|
console.error(`
|
|
102
29
|
\u274C Failed to save config file: ${err.message}`);
|
|
@@ -105,13 +32,13 @@ function saveConfig(config) {
|
|
|
105
32
|
}
|
|
106
33
|
}
|
|
107
34
|
function clearConfig() {
|
|
108
|
-
if (
|
|
109
|
-
|
|
35
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
36
|
+
fs.unlinkSync(CONFIG_FILE);
|
|
110
37
|
}
|
|
111
38
|
}
|
|
112
39
|
|
|
113
40
|
// src/cli/auth.ts
|
|
114
|
-
|
|
41
|
+
import open from "open";
|
|
115
42
|
var JUNIS_API = process.env.JUNIS_API_URL ?? "https://junis.ai";
|
|
116
43
|
var JUNIS_WEB = (() => {
|
|
117
44
|
if (process.env.JUNIS_WEB_URL) return process.env.JUNIS_WEB_URL;
|
|
@@ -157,7 +84,7 @@ async function authenticate(deviceName, platform3, onBrowserOpen, onWaiting, exi
|
|
|
157
84
|
const verificationUri = JUNIS_WEB ? startData.verification_uri.replace(/^https?:\/\/[^/]+/, JUNIS_WEB) : startData.verification_uri;
|
|
158
85
|
onBrowserOpen?.(verificationUri);
|
|
159
86
|
try {
|
|
160
|
-
await (
|
|
87
|
+
await open(verificationUri);
|
|
161
88
|
} catch {
|
|
162
89
|
console.warn(`
|
|
163
90
|
\u26A0\uFE0F Could not open browser automatically. Please open the following URL manually:
|
|
@@ -199,12 +126,45 @@ async function authenticate(deviceName, platform3, onBrowserOpen, onWaiting, exi
|
|
|
199
126
|
}
|
|
200
127
|
throw new Error("Authentication timed out (5 min). Please try again.");
|
|
201
128
|
}
|
|
129
|
+
async function ensureTeam(deviceKey, token) {
|
|
130
|
+
let res;
|
|
131
|
+
try {
|
|
132
|
+
res = await fetch(`${JUNIS_API}/api/auth/device/ensure-team`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: {
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
Authorization: `Bearer ${token}`
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify({ device_key: deviceKey })
|
|
139
|
+
});
|
|
140
|
+
} catch {
|
|
141
|
+
throw new Error("Network error: cannot reach server");
|
|
142
|
+
}
|
|
143
|
+
if (res.status === 401) {
|
|
144
|
+
return { status: "auth_expired" };
|
|
145
|
+
}
|
|
146
|
+
if (res.status === 404) {
|
|
147
|
+
return { status: "device_not_found" };
|
|
148
|
+
}
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
throw new Error(`ensure-team failed: ${res.status}`);
|
|
151
|
+
}
|
|
152
|
+
const data = await res.json();
|
|
153
|
+
return {
|
|
154
|
+
status: data.status,
|
|
155
|
+
// "ready" or "created"
|
|
156
|
+
organization_id: data.organization_id,
|
|
157
|
+
agent_id: data.agent_id,
|
|
158
|
+
agent_name: data.agent_name,
|
|
159
|
+
token: data.token
|
|
160
|
+
};
|
|
161
|
+
}
|
|
202
162
|
function sleep(ms) {
|
|
203
163
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
204
164
|
}
|
|
205
165
|
|
|
206
166
|
// src/relay/client.ts
|
|
207
|
-
|
|
167
|
+
import WebSocket from "ws";
|
|
208
168
|
var JUNIS_WS = (() => {
|
|
209
169
|
if (process.env.JUNIS_WS_URL) return process.env.JUNIS_WS_URL;
|
|
210
170
|
const apiUrl = process.env.JUNIS_API_URL ?? "https://junis.ai";
|
|
@@ -230,7 +190,7 @@ var RelayClient = class {
|
|
|
230
190
|
if (this.destroyed) return;
|
|
231
191
|
const url = `${JUNIS_WS}/ws/devices/${this.config.device_key}`;
|
|
232
192
|
console.log(`\u{1F517} Connecting to relay server...`);
|
|
233
|
-
const ws = new
|
|
193
|
+
const ws = new WebSocket(url, {
|
|
234
194
|
headers: { Authorization: `Bearer ${this.config.token}` }
|
|
235
195
|
});
|
|
236
196
|
this.ws = ws;
|
|
@@ -280,6 +240,13 @@ var RelayClient = class {
|
|
|
280
240
|
}
|
|
281
241
|
return;
|
|
282
242
|
}
|
|
243
|
+
if (code === 4004 || code === 4005) {
|
|
244
|
+
this.destroyed = true;
|
|
245
|
+
console.error(
|
|
246
|
+
"\n\u274C Device or team configuration is invalid. Run `npx junis --reset` to re-authenticate."
|
|
247
|
+
);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
283
250
|
console.log(`\u26A0\uFE0F Disconnected. Reconnecting in ${this.reconnectDelay / 1e3}s...`);
|
|
284
251
|
setTimeout(() => this.connect(), this.reconnectDelay);
|
|
285
252
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 3e4);
|
|
@@ -295,7 +262,7 @@ var RelayClient = class {
|
|
|
295
262
|
this.connect();
|
|
296
263
|
}
|
|
297
264
|
send(data) {
|
|
298
|
-
if (this.ws?.readyState ===
|
|
265
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
299
266
|
this.ws.send(JSON.stringify(data));
|
|
300
267
|
}
|
|
301
268
|
}
|
|
@@ -323,17 +290,17 @@ var RelayClient = class {
|
|
|
323
290
|
};
|
|
324
291
|
|
|
325
292
|
// src/server/mcp.ts
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
293
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
294
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
295
|
+
import { createServer } from "http";
|
|
329
296
|
|
|
330
297
|
// src/tools/filesystem.ts
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
298
|
+
import { exec, execFile } from "child_process";
|
|
299
|
+
import { promisify } from "util";
|
|
300
|
+
import fs2 from "fs/promises";
|
|
301
|
+
import path2 from "path";
|
|
302
|
+
import { glob } from "glob";
|
|
303
|
+
import { z } from "zod";
|
|
337
304
|
|
|
338
305
|
// src/server/permissions.ts
|
|
339
306
|
var toolPermissions = {
|
|
@@ -371,9 +338,9 @@ var toolPermissions = {
|
|
|
371
338
|
cron_delete: "confirm",
|
|
372
339
|
edit_block: "confirm",
|
|
373
340
|
kill_process: "confirm",
|
|
374
|
-
// 시스템 변경 —
|
|
375
|
-
execute_command: "
|
|
376
|
-
write_file: "
|
|
341
|
+
// 시스템 변경 — 기본 차단 (PDF 7.3절)
|
|
342
|
+
execute_command: "deny",
|
|
343
|
+
write_file: "deny"
|
|
377
344
|
};
|
|
378
345
|
function checkPermission(toolName) {
|
|
379
346
|
const level = toolPermissions[toolName];
|
|
@@ -385,8 +352,8 @@ function checkPermission(toolName) {
|
|
|
385
352
|
}
|
|
386
353
|
|
|
387
354
|
// src/tools/filesystem.ts
|
|
388
|
-
var execAsync =
|
|
389
|
-
var execFileAsync =
|
|
355
|
+
var execAsync = promisify(exec);
|
|
356
|
+
var execFileAsync = promisify(execFile);
|
|
390
357
|
var FilesystemTools = class {
|
|
391
358
|
register(server) {
|
|
392
359
|
server.tool(
|
|
@@ -408,14 +375,14 @@ var FilesystemTools = class {
|
|
|
408
375
|
"- Avoid piping untrusted input into shells. Use absolute paths when possible. Quote paths containing spaces."
|
|
409
376
|
].join("\n"),
|
|
410
377
|
{
|
|
411
|
-
command:
|
|
412
|
-
timeout_ms:
|
|
413
|
-
background:
|
|
378
|
+
command: z.string().describe("The shell command to execute. Use absolute paths when possible. Quote paths containing spaces."),
|
|
379
|
+
timeout_ms: z.number().optional().default(3e4).describe("Maximum execution time in milliseconds (default: 30000). Increase for long-running builds or downloads."),
|
|
380
|
+
background: z.boolean().optional().default(false).describe("Run in background without waiting for completion. Use for servers or long-running processes.")
|
|
414
381
|
},
|
|
415
382
|
async ({ command, timeout_ms, background }) => {
|
|
416
383
|
checkPermission("execute_command");
|
|
417
384
|
if (background) {
|
|
418
|
-
|
|
385
|
+
exec(command);
|
|
419
386
|
return { content: [{ type: "text", text: "Background execution started" }] };
|
|
420
387
|
}
|
|
421
388
|
try {
|
|
@@ -449,12 +416,12 @@ ${error.stderr ?? ""}`
|
|
|
449
416
|
"For searching within files, prefer search_code instead. For listing directory contents, use list_directory."
|
|
450
417
|
].join("\n"),
|
|
451
418
|
{
|
|
452
|
-
path:
|
|
453
|
-
encoding:
|
|
419
|
+
path: z.string().describe("Absolute or relative file path to read"),
|
|
420
|
+
encoding: z.enum(["utf-8", "base64"]).optional().default("utf-8").describe("'utf-8' for text files (default), 'base64' for binary files (images, PDFs, archives)")
|
|
454
421
|
},
|
|
455
422
|
async ({ path: filePath, encoding }) => {
|
|
456
423
|
try {
|
|
457
|
-
const content = await
|
|
424
|
+
const content = await fs2.readFile(filePath, encoding);
|
|
458
425
|
return { content: [{ type: "text", text: content }] };
|
|
459
426
|
} catch (err) {
|
|
460
427
|
const e = err;
|
|
@@ -474,13 +441,13 @@ ${error.stderr ?? ""}`
|
|
|
474
441
|
"Prefer edit_block over write_file for existing files \u2014 it's safer and preserves unmodified content."
|
|
475
442
|
].join("\n"),
|
|
476
443
|
{
|
|
477
|
-
path:
|
|
478
|
-
content:
|
|
444
|
+
path: z.string().describe("File path to create or overwrite. Parent directories are auto-created."),
|
|
445
|
+
content: z.string().describe("Complete file content. This replaces the entire file.")
|
|
479
446
|
},
|
|
480
447
|
async ({ path: filePath, content }) => {
|
|
481
448
|
checkPermission("write_file");
|
|
482
|
-
await
|
|
483
|
-
await
|
|
449
|
+
await fs2.mkdir(path2.dirname(filePath), { recursive: true });
|
|
450
|
+
await fs2.writeFile(filePath, content, "utf-8");
|
|
484
451
|
return { content: [{ type: "text", text: "File saved" }] };
|
|
485
452
|
}
|
|
486
453
|
);
|
|
@@ -491,11 +458,11 @@ ${error.stderr ?? ""}`
|
|
|
491
458
|
"Use this to explore project structure before reading or modifying files."
|
|
492
459
|
].join("\n"),
|
|
493
460
|
{
|
|
494
|
-
path:
|
|
461
|
+
path: z.string().describe("Directory path to list")
|
|
495
462
|
},
|
|
496
463
|
async ({ path: dirPath }) => {
|
|
497
464
|
try {
|
|
498
|
-
const entries = await
|
|
465
|
+
const entries = await fs2.readdir(dirPath, { withFileTypes: true });
|
|
499
466
|
const lines = entries.map((e) => `${e.isDirectory() ? "\u{1F4C1}" : "\u{1F4C4}"} ${e.name}`);
|
|
500
467
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
501
468
|
} catch (err) {
|
|
@@ -516,9 +483,9 @@ ${error.stderr ?? ""}`
|
|
|
516
483
|
"Returns matching lines with file paths and line numbers for precise navigation."
|
|
517
484
|
].join("\n"),
|
|
518
485
|
{
|
|
519
|
-
pattern:
|
|
520
|
-
directory:
|
|
521
|
-
file_pattern:
|
|
486
|
+
pattern: z.string().describe("Search pattern with full regex support (e.g. 'function\\s+\\w+', 'import.*from', 'TODO')"),
|
|
487
|
+
directory: z.string().optional().default(".").describe("Root directory to search from (default: current working directory)"),
|
|
488
|
+
file_pattern: z.string().optional().default("**/*").describe("Glob pattern to filter files (e.g. '**/*.ts', '*.py', 'src/**/*.js')")
|
|
522
489
|
},
|
|
523
490
|
async ({ pattern, directory, file_pattern }) => {
|
|
524
491
|
try {
|
|
@@ -529,13 +496,13 @@ ${error.stderr ?? ""}`
|
|
|
529
496
|
);
|
|
530
497
|
return { content: [{ type: "text", text: stdout || "No results" }] };
|
|
531
498
|
} catch {
|
|
532
|
-
const safeDirectory =
|
|
533
|
-
const files = await
|
|
499
|
+
const safeDirectory = path2.resolve(directory);
|
|
500
|
+
const files = await glob(file_pattern, { cwd: safeDirectory });
|
|
534
501
|
const results = [];
|
|
535
502
|
for (const file of files.slice(0, 100)) {
|
|
536
503
|
try {
|
|
537
|
-
const content = await
|
|
538
|
-
|
|
504
|
+
const content = await fs2.readFile(
|
|
505
|
+
path2.join(safeDirectory, file),
|
|
539
506
|
"utf-8"
|
|
540
507
|
);
|
|
541
508
|
const lines = content.split("\n");
|
|
@@ -572,8 +539,8 @@ ${error.stderr ?? ""}`
|
|
|
572
539
|
"SAFETY: Only kill processes the user explicitly identifies. Never kill system-critical processes (init, systemd, loginwindow, WindowServer) without explicit instruction."
|
|
573
540
|
].join("\n"),
|
|
574
541
|
{
|
|
575
|
-
pid:
|
|
576
|
-
signal:
|
|
542
|
+
pid: z.number().describe("PID of the process to terminate (use list_processes to find PIDs)"),
|
|
543
|
+
signal: z.enum(["SIGTERM", "SIGKILL"]).optional().default("SIGTERM").describe("SIGTERM (default): graceful shutdown with 3s auto-SIGKILL fallback. SIGKILL: immediate force kill.")
|
|
577
544
|
},
|
|
578
545
|
async ({ pid, signal }) => {
|
|
579
546
|
const isWindows = process.platform === "win32";
|
|
@@ -629,13 +596,13 @@ ${error.stderr ?? ""}`
|
|
|
629
596
|
"Prefer this over write_file for modifying existing files \u2014 it only changes what you specify and preserves the rest."
|
|
630
597
|
].join("\n"),
|
|
631
598
|
{
|
|
632
|
-
path:
|
|
633
|
-
old_string:
|
|
634
|
-
new_string:
|
|
635
|
-
replace_all:
|
|
599
|
+
path: z.string().describe("Path to the file to edit. The file must already exist."),
|
|
600
|
+
old_string: z.string().describe("The exact text to find and replace. Must match character-for-character including whitespace and newlines. Include enough context for uniqueness."),
|
|
601
|
+
new_string: z.string().describe("The replacement text. Use empty string to delete the matched text."),
|
|
602
|
+
replace_all: z.boolean().optional().default(false).describe("If true, replace ALL matches. If false (default), require exactly one match (errors on ambiguous multiple matches).")
|
|
636
603
|
},
|
|
637
604
|
async ({ path: filePath, old_string, new_string, replace_all }) => {
|
|
638
|
-
const content = await
|
|
605
|
+
const content = await fs2.readFile(filePath, "utf-8");
|
|
639
606
|
if (!content.includes(old_string)) {
|
|
640
607
|
throw new Error(`old_string not found in file: ${filePath}`);
|
|
641
608
|
}
|
|
@@ -659,7 +626,7 @@ ${error.stderr ?? ""}`
|
|
|
659
626
|
result = content.replace(old_string, new_string);
|
|
660
627
|
replaced = 1;
|
|
661
628
|
}
|
|
662
|
-
await
|
|
629
|
+
await fs2.writeFile(filePath, result, "utf-8");
|
|
663
630
|
return {
|
|
664
631
|
content: [{ type: "text", text: `Replaced (${replaced} occurrence(s) changed)` }]
|
|
665
632
|
};
|
|
@@ -674,9 +641,9 @@ ${error.stderr ?? ""}`
|
|
|
674
641
|
"Duplicate commands are automatically detected and rejected. Use cron_list to see existing jobs."
|
|
675
642
|
].join("\n"),
|
|
676
643
|
{
|
|
677
|
-
schedule:
|
|
678
|
-
command:
|
|
679
|
-
label:
|
|
644
|
+
schedule: z.string().describe("Cron schedule expression (5 fields: minute hour day month weekday). Examples: '*/5 * * * *' (every 5 min), '0 9 * * 1-5' (weekdays 9am)"),
|
|
645
|
+
command: z.string().describe("Shell command to execute on schedule"),
|
|
646
|
+
label: z.string().optional().describe("Human-readable label for identification (e.g. 'daily-backup', 'log-cleanup')")
|
|
680
647
|
},
|
|
681
648
|
async ({ schedule, command, label }) => {
|
|
682
649
|
try {
|
|
@@ -698,9 +665,9 @@ ${error.stderr ?? ""}`
|
|
|
698
665
|
`;
|
|
699
666
|
const updated = existing.trimEnd() + "\n" + newEntry;
|
|
700
667
|
const tmpFile = `/tmp/junis_crontab_${Date.now()}.txt`;
|
|
701
|
-
await
|
|
668
|
+
await fs2.writeFile(tmpFile, updated, "utf-8");
|
|
702
669
|
await execAsync(`crontab ${tmpFile}`);
|
|
703
|
-
await
|
|
670
|
+
await fs2.unlink(tmpFile).catch(() => {
|
|
704
671
|
});
|
|
705
672
|
return {
|
|
706
673
|
content: [{ type: "text", text: `\u2705 Cron job created:
|
|
@@ -767,8 +734,8 @@ ${error.stderr ?? ""}`
|
|
|
767
734
|
"cron_delete",
|
|
768
735
|
"Delete a scheduled cron job by its ID (from cron_list output) or by matching command string. Associated comment labels are automatically cleaned up.",
|
|
769
736
|
{
|
|
770
|
-
id:
|
|
771
|
-
command:
|
|
737
|
+
id: z.number().optional().describe("Cron job ID from cron_list output (e.g. 1, 2, 3)"),
|
|
738
|
+
command: z.string().optional().describe("Delete all jobs matching this command string")
|
|
772
739
|
},
|
|
773
740
|
async ({ id, command }) => {
|
|
774
741
|
if (!id && !command) {
|
|
@@ -805,9 +772,9 @@ ${error.stderr ?? ""}`
|
|
|
805
772
|
}
|
|
806
773
|
const updated2 = filtered2.join("\n");
|
|
807
774
|
const tmpFile2 = `/tmp/junis_crontab_${Date.now()}.txt`;
|
|
808
|
-
await
|
|
775
|
+
await fs2.writeFile(tmpFile2, updated2, "utf-8");
|
|
809
776
|
await execAsync(`crontab ${tmpFile2}`);
|
|
810
|
-
await
|
|
777
|
+
await fs2.unlink(tmpFile2).catch(() => {
|
|
811
778
|
});
|
|
812
779
|
return { content: [{ type: "text", text: `\u2705 Deleted cron job matching: ${command}` }] };
|
|
813
780
|
}
|
|
@@ -832,9 +799,9 @@ ${error.stderr ?? ""}`
|
|
|
832
799
|
const filtered = lines.filter((_, i) => i < target.lineStart || i > target.lineEnd);
|
|
833
800
|
const updated = filtered.join("\n");
|
|
834
801
|
const tmpFile = `/tmp/junis_crontab_${Date.now()}.txt`;
|
|
835
|
-
await
|
|
802
|
+
await fs2.writeFile(tmpFile, updated, "utf-8");
|
|
836
803
|
await execAsync(`crontab ${tmpFile}`);
|
|
837
|
-
await
|
|
804
|
+
await fs2.unlink(tmpFile).catch(() => {
|
|
838
805
|
});
|
|
839
806
|
return { content: [{ type: "text", text: `\u2705 Deleted cron job #${id}` }] };
|
|
840
807
|
} catch (err) {
|
|
@@ -849,9 +816,9 @@ ${error.stderr ?? ""}`
|
|
|
849
816
|
};
|
|
850
817
|
|
|
851
818
|
// src/tools/browser.ts
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
819
|
+
import { BrowserClaw } from "browserclaw";
|
|
820
|
+
import fs3 from "fs/promises";
|
|
821
|
+
import { z as z2 } from "zod";
|
|
855
822
|
var BrowserTools = class {
|
|
856
823
|
browser = null;
|
|
857
824
|
page = null;
|
|
@@ -892,11 +859,11 @@ var BrowserTools = class {
|
|
|
892
859
|
"Always call browser_stop when done to release system resources."
|
|
893
860
|
].join("\n"),
|
|
894
861
|
{
|
|
895
|
-
mode:
|
|
896
|
-
headless:
|
|
897
|
-
cdpUrl:
|
|
898
|
-
profile:
|
|
899
|
-
allowInternal:
|
|
862
|
+
mode: z2.enum(["managed", "remote-cdp"]).optional().default("managed").describe("'managed' = launch new browser, 'remote-cdp' = connect to existing Chrome via CDP"),
|
|
863
|
+
headless: z2.boolean().optional().default(false).describe("Run without visible window (managed mode only). Use for background tasks."),
|
|
864
|
+
cdpUrl: z2.string().optional().describe("Chrome DevTools Protocol URL for remote-cdp mode (e.g. http://localhost:9222)"),
|
|
865
|
+
profile: z2.string().optional().describe("Browser profile name for persistent sessions \u2014 preserves cookies, logins, and history across restarts (managed mode only)"),
|
|
866
|
+
allowInternal: z2.boolean().optional().default(false).describe("Allow navigation to localhost and internal network URLs")
|
|
900
867
|
},
|
|
901
868
|
({ mode, headless, cdpUrl, profile, allowInternal }) => this.withLock(async () => {
|
|
902
869
|
if (this.browser) {
|
|
@@ -904,9 +871,9 @@ var BrowserTools = class {
|
|
|
904
871
|
}
|
|
905
872
|
if (mode === "remote-cdp") {
|
|
906
873
|
if (!cdpUrl) throw new Error("cdpUrl is required for remote-cdp mode");
|
|
907
|
-
this.browser = await
|
|
874
|
+
this.browser = await BrowserClaw.connect(cdpUrl, { allowInternal });
|
|
908
875
|
} else {
|
|
909
|
-
this.browser = await
|
|
876
|
+
this.browser = await BrowserClaw.launch({
|
|
910
877
|
headless,
|
|
911
878
|
profileName: profile,
|
|
912
879
|
allowInternal
|
|
@@ -928,7 +895,7 @@ var BrowserTools = class {
|
|
|
928
895
|
"browser_navigate",
|
|
929
896
|
"Navigate the browser to a URL. Automatically opens a new tab if the browser is started but no page exists yet. Waits for the page to load before returning.",
|
|
930
897
|
{
|
|
931
|
-
url:
|
|
898
|
+
url: z2.string().describe("Full URL to navigate to (include https://)")
|
|
932
899
|
},
|
|
933
900
|
({ url }) => this.withLock(async () => {
|
|
934
901
|
if (!this.browser) throw new Error("Browser not started. Call browser_start first.");
|
|
@@ -952,8 +919,8 @@ var BrowserTools = class {
|
|
|
952
919
|
"Prefer this over browser_screenshot for understanding page structure \u2014 it's faster, structured, and machine-readable."
|
|
953
920
|
].join("\n"),
|
|
954
921
|
{
|
|
955
|
-
interactive:
|
|
956
|
-
compact:
|
|
922
|
+
interactive: z2.boolean().optional().default(true).describe("true (default): only show clickable/typeable elements. false: show all elements including static text."),
|
|
923
|
+
compact: z2.boolean().optional().default(true).describe("true (default): hide empty containers for cleaner output")
|
|
957
924
|
},
|
|
958
925
|
({ interactive, compact }) => this.withLock(async () => {
|
|
959
926
|
const result = await requirePage().snapshot({ interactive, compact });
|
|
@@ -975,9 +942,9 @@ ${refList}`
|
|
|
975
942
|
"browser_click",
|
|
976
943
|
"Click an element by its ref number from browser_snapshot. Always call browser_snapshot first to get current refs \u2014 they change after page updates.",
|
|
977
944
|
{
|
|
978
|
-
ref:
|
|
979
|
-
doubleClick:
|
|
980
|
-
button:
|
|
945
|
+
ref: z2.string().describe("Element ref from browser_snapshot (e.g. 'e1', 'e15'). Call browser_snapshot first to get current refs."),
|
|
946
|
+
doubleClick: z2.boolean().optional().default(false).describe("Double-click instead of single click"),
|
|
947
|
+
button: z2.enum(["left", "right", "middle"]).optional().default("left").describe("Mouse button to use")
|
|
981
948
|
},
|
|
982
949
|
({ ref, doubleClick, button }) => this.withLock(async () => {
|
|
983
950
|
await requirePage().click(ref, { doubleClick, button });
|
|
@@ -988,10 +955,10 @@ ${refList}`
|
|
|
988
955
|
"browser_type",
|
|
989
956
|
"Type text into an input element by ref number. Use 'submit=true' to press Enter after typing (e.g. for search forms). Use 'slowly=true' for sites requiring keystroke-by-keystroke input.",
|
|
990
957
|
{
|
|
991
|
-
ref:
|
|
992
|
-
text:
|
|
993
|
-
submit:
|
|
994
|
-
slowly:
|
|
958
|
+
ref: z2.string().describe("Element ref from browser_snapshot (e.g. 'e3')"),
|
|
959
|
+
text: z2.string().describe("Text to type into the element"),
|
|
960
|
+
submit: z2.boolean().optional().default(false).describe("Press Enter after typing (useful for search boxes and forms)"),
|
|
961
|
+
slowly: z2.boolean().optional().default(false).describe("Type slowly (75ms per char) for sites that process each keystroke")
|
|
995
962
|
},
|
|
996
963
|
({ ref, text, submit, slowly }) => this.withLock(async () => {
|
|
997
964
|
await requirePage().type(ref, text, { submit, slowly });
|
|
@@ -1002,10 +969,10 @@ ${refList}`
|
|
|
1002
969
|
"browser_fill",
|
|
1003
970
|
"Fill multiple form fields at once \u2014 more efficient than calling browser_type repeatedly. Each field needs a ref from browser_snapshot.",
|
|
1004
971
|
{
|
|
1005
|
-
fields:
|
|
1006
|
-
ref:
|
|
1007
|
-
type:
|
|
1008
|
-
value:
|
|
972
|
+
fields: z2.array(z2.object({
|
|
973
|
+
ref: z2.string(),
|
|
974
|
+
type: z2.enum(["text", "checkbox", "radio"]),
|
|
975
|
+
value: z2.union([z2.string(), z2.boolean()])
|
|
1009
976
|
})).describe("Array of {ref, type, value}. type='text': value is string. type='checkbox'/'radio': value is boolean.")
|
|
1010
977
|
},
|
|
1011
978
|
({ fields }) => this.withLock(async () => {
|
|
@@ -1017,8 +984,8 @@ ${refList}`
|
|
|
1017
984
|
"browser_select",
|
|
1018
985
|
"Select one or more options from a dropdown/select element. Values should match the option value attributes, not display text.",
|
|
1019
986
|
{
|
|
1020
|
-
ref:
|
|
1021
|
-
values:
|
|
987
|
+
ref: z2.string().describe("Ref of the <select> element from browser_snapshot"),
|
|
988
|
+
values: z2.array(z2.string()).describe("Option value(s) to select")
|
|
1022
989
|
},
|
|
1023
990
|
({ ref, values }) => this.withLock(async () => {
|
|
1024
991
|
await requirePage().select(ref, ...values);
|
|
@@ -1029,7 +996,7 @@ ${refList}`
|
|
|
1029
996
|
"browser_press",
|
|
1030
997
|
"Press a keyboard key or key combination. Use for shortcuts (e.g. 'Control+a', 'Escape'), form submission ('Enter'), or navigation ('Tab'). Does not require a specific element ref.",
|
|
1031
998
|
{
|
|
1032
|
-
key:
|
|
999
|
+
key: z2.string().describe("Key or combination: 'Enter', 'Escape', 'Tab', 'Control+a', 'Meta+c', 'ArrowDown', 'Backspace'")
|
|
1033
1000
|
},
|
|
1034
1001
|
({ key }) => this.withLock(async () => {
|
|
1035
1002
|
await requirePage().press(key);
|
|
@@ -1040,7 +1007,7 @@ ${refList}`
|
|
|
1040
1007
|
"browser_hover",
|
|
1041
1008
|
"Move the mouse cursor over an element by ref. Use to trigger hover menus, tooltips, or dropdown previews before clicking.",
|
|
1042
1009
|
{
|
|
1043
|
-
ref:
|
|
1010
|
+
ref: z2.string().describe("Element ref from browser_snapshot")
|
|
1044
1011
|
},
|
|
1045
1012
|
({ ref }) => this.withLock(async () => {
|
|
1046
1013
|
await requirePage().hover(ref);
|
|
@@ -1051,8 +1018,8 @@ ${refList}`
|
|
|
1051
1018
|
"browser_drag",
|
|
1052
1019
|
"Drag an element from startRef to endRef. Both refs must come from a recent browser_snapshot. Use for drag-and-drop interfaces, sliders, or reorderable lists.",
|
|
1053
1020
|
{
|
|
1054
|
-
startRef:
|
|
1055
|
-
endRef:
|
|
1021
|
+
startRef: z2.string().describe("Source element ref to drag from"),
|
|
1022
|
+
endRef: z2.string().describe("Target element ref to drag to")
|
|
1056
1023
|
},
|
|
1057
1024
|
({ startRef, endRef }) => this.withLock(async () => {
|
|
1058
1025
|
await requirePage().drag(startRef, endRef);
|
|
@@ -1063,8 +1030,8 @@ ${refList}`
|
|
|
1063
1030
|
"browser_upload",
|
|
1064
1031
|
"Upload local files to a file input element (<input type='file'>). The ref must point to a file input from browser_snapshot.",
|
|
1065
1032
|
{
|
|
1066
|
-
ref:
|
|
1067
|
-
paths:
|
|
1033
|
+
ref: z2.string().describe("Ref of the file input element from browser_snapshot"),
|
|
1034
|
+
paths: z2.array(z2.string()).describe("Absolute file path(s) on the local device to upload")
|
|
1068
1035
|
},
|
|
1069
1036
|
({ ref, paths }) => this.withLock(async () => {
|
|
1070
1037
|
await requirePage().uploadFile(ref, paths);
|
|
@@ -1080,14 +1047,14 @@ ${refList}`
|
|
|
1080
1047
|
"Use browser_screenshot only when visual layout matters (charts, images, styling, visual verification)."
|
|
1081
1048
|
].join("\n"),
|
|
1082
1049
|
{
|
|
1083
|
-
path:
|
|
1084
|
-
fullPage:
|
|
1085
|
-
ref:
|
|
1050
|
+
path: z2.string().optional().describe("Save path for the screenshot. If omitted, returns base64 image data directly."),
|
|
1051
|
+
fullPage: z2.boolean().optional().default(false).describe("Capture the full scrollable page, not just the visible viewport"),
|
|
1052
|
+
ref: z2.string().optional().describe("Capture only a specific element by its ref from browser_snapshot")
|
|
1086
1053
|
},
|
|
1087
1054
|
({ path: path4, fullPage, ref }) => this.withLock(async () => {
|
|
1088
1055
|
const buffer = await requirePage().screenshot({ fullPage, ref });
|
|
1089
1056
|
if (path4) {
|
|
1090
|
-
await
|
|
1057
|
+
await fs3.writeFile(path4, buffer);
|
|
1091
1058
|
return { content: [{ type: "text", text: `Screenshot saved: ${path4}` }] };
|
|
1092
1059
|
}
|
|
1093
1060
|
return {
|
|
@@ -1103,11 +1070,11 @@ ${refList}`
|
|
|
1103
1070
|
"browser_pdf",
|
|
1104
1071
|
"Save the current page as a PDF file. Renders the full page including below-the-fold content. Useful for archiving, sharing, or offline reading.",
|
|
1105
1072
|
{
|
|
1106
|
-
path:
|
|
1073
|
+
path: z2.string().describe("Output file path (.pdf)")
|
|
1107
1074
|
},
|
|
1108
1075
|
({ path: path4 }) => this.withLock(async () => {
|
|
1109
1076
|
const buffer = await requirePage().pdf();
|
|
1110
|
-
await
|
|
1077
|
+
await fs3.writeFile(path4, buffer);
|
|
1111
1078
|
return { content: [{ type: "text", text: `PDF saved: ${path4}` }] };
|
|
1112
1079
|
})
|
|
1113
1080
|
);
|
|
@@ -1120,7 +1087,7 @@ ${refList}`
|
|
|
1120
1087
|
"Wrap complex logic in an IIFE: (function(){ ... })()"
|
|
1121
1088
|
].join("\n"),
|
|
1122
1089
|
{
|
|
1123
|
-
code:
|
|
1090
|
+
code: z2.string().describe("JavaScript code to execute in the page context. Return values are automatically serialized.")
|
|
1124
1091
|
},
|
|
1125
1092
|
({ code }) => this.withLock(async () => {
|
|
1126
1093
|
try {
|
|
@@ -1147,11 +1114,11 @@ ${refList}`
|
|
|
1147
1114
|
"OPTIONS (use one): 'text' (wait for text to appear), 'textGone' (wait for text to disappear), 'url' (URL matches glob), 'loadState' (page load state), 'timeMs' (fixed delay as last resort)."
|
|
1148
1115
|
].join("\n"),
|
|
1149
1116
|
{
|
|
1150
|
-
text:
|
|
1151
|
-
textGone:
|
|
1152
|
-
url:
|
|
1153
|
-
loadState:
|
|
1154
|
-
timeMs:
|
|
1117
|
+
text: z2.string().optional().describe("Wait until this text appears on the page"),
|
|
1118
|
+
textGone: z2.string().optional().describe("Wait until this text disappears from the page"),
|
|
1119
|
+
url: z2.string().optional().describe("Wait until URL matches this glob pattern (e.g. '**/dashboard', '**/success')"),
|
|
1120
|
+
loadState: z2.enum(["load", "domcontentloaded", "networkidle"]).optional().describe("Wait for page load state: 'load' (full), 'domcontentloaded' (DOM ready), 'networkidle' (no pending requests)"),
|
|
1121
|
+
timeMs: z2.number().optional().describe("Fixed wait in milliseconds \u2014 use as last resort when other conditions don't apply")
|
|
1155
1122
|
},
|
|
1156
1123
|
({ text, textGone, url, loadState, timeMs }) => this.withLock(async () => {
|
|
1157
1124
|
const condition = {};
|
|
@@ -1168,14 +1135,14 @@ ${refList}`
|
|
|
1168
1135
|
"browser_cookies",
|
|
1169
1136
|
"Manage browser cookies: get all cookies, set a specific cookie, or clear all cookies. Useful for authentication state, session management, or testing.",
|
|
1170
1137
|
{
|
|
1171
|
-
action:
|
|
1172
|
-
cookie:
|
|
1173
|
-
name:
|
|
1174
|
-
value:
|
|
1175
|
-
domain:
|
|
1176
|
-
path:
|
|
1177
|
-
httpOnly:
|
|
1178
|
-
secure:
|
|
1138
|
+
action: z2.enum(["get", "set", "clear"]).describe("'get': retrieve all cookies, 'set': add/update a cookie, 'clear': remove all cookies"),
|
|
1139
|
+
cookie: z2.object({
|
|
1140
|
+
name: z2.string(),
|
|
1141
|
+
value: z2.string(),
|
|
1142
|
+
domain: z2.string().optional(),
|
|
1143
|
+
path: z2.string().optional(),
|
|
1144
|
+
httpOnly: z2.boolean().optional(),
|
|
1145
|
+
secure: z2.boolean().optional()
|
|
1179
1146
|
}).optional().describe("Cookie data (required for 'set' action)")
|
|
1180
1147
|
},
|
|
1181
1148
|
({ action, cookie }) => this.withLock(async () => {
|
|
@@ -1197,10 +1164,10 @@ ${refList}`
|
|
|
1197
1164
|
"browser_storage",
|
|
1198
1165
|
"Read, write, or clear browser localStorage/sessionStorage. Useful for managing client-side state, authentication tokens, or application preferences.",
|
|
1199
1166
|
{
|
|
1200
|
-
action:
|
|
1201
|
-
kind:
|
|
1202
|
-
key:
|
|
1203
|
-
value:
|
|
1167
|
+
action: z2.enum(["get", "set", "clear"]).describe("'get': read value(s), 'set': write a key-value pair, 'clear': remove all entries"),
|
|
1168
|
+
kind: z2.enum(["local", "session"]).optional().default("local").describe("'local' (persistent) or 'session' (cleared on tab close)"),
|
|
1169
|
+
key: z2.string().optional().describe("Storage key to get or set. Omit key with 'get' to retrieve all entries."),
|
|
1170
|
+
value: z2.string().optional().describe("Value to store (required for 'set' action)")
|
|
1204
1171
|
},
|
|
1205
1172
|
({ action, kind, key, value }) => this.withLock(async () => {
|
|
1206
1173
|
const page = requirePage();
|
|
@@ -1228,16 +1195,16 @@ ${refList}`
|
|
|
1228
1195
|
"The 'accept' and 'promptText' params are only used with action='arm'."
|
|
1229
1196
|
].join("\n"),
|
|
1230
1197
|
{
|
|
1231
|
-
action:
|
|
1198
|
+
action: z2.enum(["arm", "wait"]).describe(
|
|
1232
1199
|
"'arm' = register handler and return immediately; 'wait' = await the previously armed handler"
|
|
1233
1200
|
),
|
|
1234
|
-
accept:
|
|
1201
|
+
accept: z2.boolean().optional().default(true).describe(
|
|
1235
1202
|
"Accept (true) or dismiss (false) the dialog. Only used with action='arm'."
|
|
1236
1203
|
),
|
|
1237
|
-
promptText:
|
|
1204
|
+
promptText: z2.string().optional().describe(
|
|
1238
1205
|
"Text to enter if the dialog is a prompt. Only used with action='arm'."
|
|
1239
1206
|
),
|
|
1240
|
-
timeoutMs:
|
|
1207
|
+
timeoutMs: z2.number().optional().describe(
|
|
1241
1208
|
"Timeout in ms for 'wait' action (default: 30000). Increase for slow-loading dialogs."
|
|
1242
1209
|
)
|
|
1243
1210
|
},
|
|
@@ -1269,13 +1236,13 @@ ${refList}`
|
|
|
1269
1236
|
};
|
|
1270
1237
|
|
|
1271
1238
|
// src/tools/notebook.ts
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
var execAsync2 = (
|
|
1239
|
+
import { z as z3 } from "zod";
|
|
1240
|
+
import fs4 from "fs/promises";
|
|
1241
|
+
import { exec as exec2 } from "child_process";
|
|
1242
|
+
import { promisify as promisify2 } from "util";
|
|
1243
|
+
var execAsync2 = promisify2(exec2);
|
|
1277
1244
|
async function readNotebook(filePath) {
|
|
1278
|
-
const raw = await
|
|
1245
|
+
const raw = await fs4.readFile(filePath, "utf-8");
|
|
1279
1246
|
try {
|
|
1280
1247
|
return JSON.parse(raw);
|
|
1281
1248
|
} catch {
|
|
@@ -1283,14 +1250,14 @@ async function readNotebook(filePath) {
|
|
|
1283
1250
|
}
|
|
1284
1251
|
}
|
|
1285
1252
|
async function writeNotebook(filePath, nb) {
|
|
1286
|
-
await
|
|
1253
|
+
await fs4.writeFile(filePath, JSON.stringify(nb, null, 1), "utf-8");
|
|
1287
1254
|
}
|
|
1288
1255
|
var NotebookTools = class {
|
|
1289
1256
|
register(server) {
|
|
1290
1257
|
server.tool(
|
|
1291
1258
|
"notebook_read",
|
|
1292
1259
|
"Read a Jupyter notebook (.ipynb) and return all cells with their types (code/markdown), source content, and output counts. Use this to understand notebook structure before making edits.",
|
|
1293
|
-
{ path:
|
|
1260
|
+
{ path: z3.string().describe("Path to the .ipynb notebook file") },
|
|
1294
1261
|
async ({ path: filePath }) => {
|
|
1295
1262
|
const nb = await readNotebook(filePath);
|
|
1296
1263
|
const cells = nb.cells.map((cell, i) => ({
|
|
@@ -1308,9 +1275,9 @@ var NotebookTools = class {
|
|
|
1308
1275
|
"notebook_edit_cell",
|
|
1309
1276
|
"Replace the source code of a specific cell in a Jupyter notebook. Use notebook_read first to identify the correct cell index (0-based). Existing outputs for the cell are preserved \u2014 use notebook_execute to re-run.",
|
|
1310
1277
|
{
|
|
1311
|
-
path:
|
|
1312
|
-
cell_index:
|
|
1313
|
-
source:
|
|
1278
|
+
path: z3.string().describe("Path to the .ipynb notebook file"),
|
|
1279
|
+
cell_index: z3.number().describe("Cell index to edit (0-based). Use notebook_read to find the right index."),
|
|
1280
|
+
source: z3.string().describe("New source code/content for the cell (replaces entire cell content)")
|
|
1314
1281
|
},
|
|
1315
1282
|
async ({ path: filePath, cell_index, source }) => {
|
|
1316
1283
|
const nb = await readNotebook(filePath);
|
|
@@ -1333,8 +1300,8 @@ var NotebookTools = class {
|
|
|
1333
1300
|
"If execution fails on a cell, the error is captured in the cell output and subsequent cells may not execute."
|
|
1334
1301
|
].join("\n"),
|
|
1335
1302
|
{
|
|
1336
|
-
path:
|
|
1337
|
-
timeout:
|
|
1303
|
+
path: z3.string().describe("Path to the .ipynb notebook file to execute"),
|
|
1304
|
+
timeout: z3.number().optional().default(300).describe("Maximum execution time per cell in seconds (default: 300). Increase for cells with heavy computation.")
|
|
1338
1305
|
},
|
|
1339
1306
|
async ({ path: filePath, timeout }) => {
|
|
1340
1307
|
const nbconvertArgs = `nbconvert --to notebook --execute --inplace "${filePath}" --ExecutePreprocessor.timeout=${timeout}`;
|
|
@@ -1365,10 +1332,10 @@ var NotebookTools = class {
|
|
|
1365
1332
|
"notebook_add_cell",
|
|
1366
1333
|
"Insert a new cell into a Jupyter notebook. If position is omitted, the cell is appended at the end. Use cell_type='code' for executable Python cells, 'markdown' for documentation/text cells.",
|
|
1367
1334
|
{
|
|
1368
|
-
path:
|
|
1369
|
-
cell_type:
|
|
1370
|
-
source:
|
|
1371
|
-
position:
|
|
1335
|
+
path: z3.string().describe("Path to the .ipynb notebook file"),
|
|
1336
|
+
cell_type: z3.enum(["code", "markdown"]).describe("'code' for executable cells, 'markdown' for text/documentation cells"),
|
|
1337
|
+
source: z3.string().describe("Cell source content (Python code or Markdown text)"),
|
|
1338
|
+
position: z3.number().optional().describe("Insert position (0-based index). Omit to append at the end. If position exceeds cell count, appends at end with a warning.")
|
|
1372
1339
|
},
|
|
1373
1340
|
async ({ path: filePath, cell_type: cellType, source, position }) => {
|
|
1374
1341
|
const nb = await readNotebook(filePath);
|
|
@@ -1401,8 +1368,8 @@ var NotebookTools = class {
|
|
|
1401
1368
|
"notebook_delete_cell",
|
|
1402
1369
|
"Delete a cell from a Jupyter notebook by its 0-based index. Use notebook_read first to verify the cell content before deletion. This action cannot be undone.",
|
|
1403
1370
|
{
|
|
1404
|
-
path:
|
|
1405
|
-
cell_index:
|
|
1371
|
+
path: z3.string().describe("Path to the .ipynb notebook file"),
|
|
1372
|
+
cell_index: z3.number().describe("Cell index to delete (0-based). Use notebook_read first to verify content.")
|
|
1406
1373
|
},
|
|
1407
1374
|
async ({ path: filePath, cell_index }) => {
|
|
1408
1375
|
const nb = await readNotebook(filePath);
|
|
@@ -1418,11 +1385,11 @@ var NotebookTools = class {
|
|
|
1418
1385
|
};
|
|
1419
1386
|
|
|
1420
1387
|
// src/tools/device.ts
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
var execAsync3 = (
|
|
1388
|
+
import { exec as exec3 } from "child_process";
|
|
1389
|
+
import { promisify as promisify3 } from "util";
|
|
1390
|
+
import { z as z4 } from "zod";
|
|
1391
|
+
import notifier from "node-notifier";
|
|
1392
|
+
var execAsync3 = promisify3(exec3);
|
|
1426
1393
|
var screenRecordPid = null;
|
|
1427
1394
|
function platform() {
|
|
1428
1395
|
if (process.platform === "darwin") return "mac";
|
|
@@ -1440,7 +1407,7 @@ var DeviceTools = class {
|
|
|
1440
1407
|
"Requires a connected camera with OS permissions granted. If output_path is provided, the file is also saved to disk."
|
|
1441
1408
|
].join("\n"),
|
|
1442
1409
|
{
|
|
1443
|
-
output_path:
|
|
1410
|
+
output_path: z4.string().optional().describe("File path to save the captured photo. If omitted, returns image data only (temp file auto-cleaned).")
|
|
1444
1411
|
},
|
|
1445
1412
|
async ({ output_path }) => {
|
|
1446
1413
|
const p = platform();
|
|
@@ -1478,13 +1445,13 @@ Please check if a camera is connected.` }],
|
|
|
1478
1445
|
"notification_send",
|
|
1479
1446
|
"Send a native OS notification (banner/toast) to the user's desktop. Use for task completion alerts, reminders, or important status updates. The notification appears even when the terminal is not focused.",
|
|
1480
1447
|
{
|
|
1481
|
-
title:
|
|
1482
|
-
message:
|
|
1448
|
+
title: z4.string().describe("Notification title (displayed prominently)"),
|
|
1449
|
+
message: z4.string().describe("Notification body text")
|
|
1483
1450
|
},
|
|
1484
1451
|
async ({ title, message }) => {
|
|
1485
1452
|
try {
|
|
1486
1453
|
await new Promise((resolve, reject) => {
|
|
1487
|
-
|
|
1454
|
+
notifier.notify(
|
|
1488
1455
|
{ title, message },
|
|
1489
1456
|
(err) => {
|
|
1490
1457
|
if (err) reject(err);
|
|
@@ -1516,7 +1483,7 @@ Please check if a camera is connected.` }],
|
|
|
1516
1483
|
"clipboard_write",
|
|
1517
1484
|
"Write text to the system clipboard, replacing its current contents. Use to prepare content for the user to paste elsewhere.",
|
|
1518
1485
|
{
|
|
1519
|
-
text:
|
|
1486
|
+
text: z4.string().describe("Text to copy to the clipboard")
|
|
1520
1487
|
},
|
|
1521
1488
|
async ({ text }) => {
|
|
1522
1489
|
const p = platform();
|
|
@@ -1538,8 +1505,8 @@ Please check if a camera is connected.` }],
|
|
|
1538
1505
|
"Platform-specific: macOS (screencapture -v), Windows/Linux (ffmpeg)."
|
|
1539
1506
|
].join("\n"),
|
|
1540
1507
|
{
|
|
1541
|
-
action:
|
|
1542
|
-
output_path:
|
|
1508
|
+
action: z4.enum(["start", "stop"]).describe("'start': begin recording, 'stop': end recording and save the file"),
|
|
1509
|
+
output_path: z4.string().optional().describe("Output file path (used with 'start'). Default: /tmp/junis_record_<timestamp>.mp4")
|
|
1543
1510
|
},
|
|
1544
1511
|
async ({ action, output_path }) => {
|
|
1545
1512
|
const p = platform();
|
|
@@ -1604,7 +1571,7 @@ Please check if a camera is connected.` }],
|
|
|
1604
1571
|
"audio_play",
|
|
1605
1572
|
"Play an audio file through the device's speakers. Supports MP3, WAV, AAC, and other common formats. Playback is synchronous \u2014 the tool returns after playback completes. Platform-specific: macOS (afplay), Windows/Linux (ffplay).",
|
|
1606
1573
|
{
|
|
1607
|
-
file_path:
|
|
1574
|
+
file_path: z4.string().describe("Absolute path to the audio file to play")
|
|
1608
1575
|
},
|
|
1609
1576
|
async ({ file_path }) => {
|
|
1610
1577
|
const p = platform();
|
|
@@ -1621,12 +1588,12 @@ Please check if a camera is connected.` }],
|
|
|
1621
1588
|
};
|
|
1622
1589
|
|
|
1623
1590
|
// src/setup/peekaboo-installer.ts
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
var execFileAsync2 = (
|
|
1591
|
+
import { execFile as execFile2 } from "child_process";
|
|
1592
|
+
import { promisify as promisify4 } from "util";
|
|
1593
|
+
import { platform as platform2 } from "os";
|
|
1594
|
+
var execFileAsync2 = promisify4(execFile2);
|
|
1628
1595
|
async function ensurePeekaboo() {
|
|
1629
|
-
if ((
|
|
1596
|
+
if (platform2() !== "darwin") return false;
|
|
1630
1597
|
try {
|
|
1631
1598
|
await execFileAsync2("which", ["peekaboo"]);
|
|
1632
1599
|
return true;
|
|
@@ -1646,9 +1613,9 @@ async function ensurePeekaboo() {
|
|
|
1646
1613
|
}
|
|
1647
1614
|
|
|
1648
1615
|
// src/tools/desktop.ts
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1616
|
+
import { execa } from "execa";
|
|
1617
|
+
import { z as z5 } from "zod";
|
|
1618
|
+
import fs5 from "fs";
|
|
1652
1619
|
var APP_BLACKLIST = /* @__PURE__ */ new Set([
|
|
1653
1620
|
"Terminal",
|
|
1654
1621
|
"iTerm2",
|
|
@@ -1661,7 +1628,7 @@ var MAX_CONSECUTIVE_FAILURES = 2;
|
|
|
1661
1628
|
async function peekaboo(args) {
|
|
1662
1629
|
consecutiveFailures = 0;
|
|
1663
1630
|
try {
|
|
1664
|
-
const { stdout } = await
|
|
1631
|
+
const { stdout } = await execa("peekaboo", [...args, "--json-output"]);
|
|
1665
1632
|
consecutiveFailures = 0;
|
|
1666
1633
|
return JSON.parse(stdout);
|
|
1667
1634
|
} catch (err) {
|
|
@@ -1691,7 +1658,7 @@ var DesktopTools = class {
|
|
|
1691
1658
|
"SAFETY: Terminal, iTerm, and Finder are blocked. Two consecutive failures trigger an automatic safety stop."
|
|
1692
1659
|
].join("\n"),
|
|
1693
1660
|
{
|
|
1694
|
-
app:
|
|
1661
|
+
app: z5.string().optional().describe("App name to target (e.g. 'Safari', 'Notes', 'Google Chrome'). Omit for the frontmost app.")
|
|
1695
1662
|
},
|
|
1696
1663
|
async ({ app }) => {
|
|
1697
1664
|
checkBlacklist(app);
|
|
@@ -1725,10 +1692,10 @@ var DesktopTools = class {
|
|
|
1725
1692
|
"SAFETY: Terminal, iTerm, and Finder are blocked. Two consecutive failures trigger automatic safety stop."
|
|
1726
1693
|
].join("\n"),
|
|
1727
1694
|
{
|
|
1728
|
-
on:
|
|
1729
|
-
app:
|
|
1730
|
-
snapshot:
|
|
1731
|
-
doubleClick:
|
|
1695
|
+
on: z5.string().describe("Element label, accessibility ID, or 'x,y' coordinates to click"),
|
|
1696
|
+
app: z5.string().optional().describe("App name to target (e.g. 'Safari')"),
|
|
1697
|
+
snapshot: z5.string().optional().describe("snapshotId from desktop_see for cached interaction (240x faster)"),
|
|
1698
|
+
doubleClick: z5.boolean().optional().default(false).describe("Double-click instead of single click")
|
|
1732
1699
|
},
|
|
1733
1700
|
async ({ on, app, snapshot, doubleClick }) => {
|
|
1734
1701
|
checkBlacklist(app);
|
|
@@ -1750,8 +1717,8 @@ var DesktopTools = class {
|
|
|
1750
1717
|
"SAFETY: Terminal, iTerm, and Finder are blocked. Use desktop_see first to verify the correct element is focused."
|
|
1751
1718
|
].join("\n"),
|
|
1752
1719
|
{
|
|
1753
|
-
text:
|
|
1754
|
-
app:
|
|
1720
|
+
text: z5.string().describe("Text to type into the focused element"),
|
|
1721
|
+
app: z5.string().optional().describe("App name to focus before typing")
|
|
1755
1722
|
},
|
|
1756
1723
|
async ({ text, app }) => {
|
|
1757
1724
|
checkBlacklist(app);
|
|
@@ -1773,8 +1740,8 @@ var DesktopTools = class {
|
|
|
1773
1740
|
"SAFETY: Terminal, iTerm, and Finder are blocked."
|
|
1774
1741
|
].join("\n"),
|
|
1775
1742
|
{
|
|
1776
|
-
keys:
|
|
1777
|
-
app:
|
|
1743
|
+
keys: z5.string().describe("Comma-separated key combination (e.g. 'cmd,c', 'cmd,shift,t', 'escape', 'cmd,option,i')"),
|
|
1744
|
+
app: z5.string().optional().describe("App name to target")
|
|
1778
1745
|
},
|
|
1779
1746
|
async ({ keys, app }) => {
|
|
1780
1747
|
checkBlacklist(app);
|
|
@@ -1790,10 +1757,10 @@ var DesktopTools = class {
|
|
|
1790
1757
|
"desktop_scroll",
|
|
1791
1758
|
"Scroll within a macOS application or specific UI element. Use 'ticks' to control scroll distance (default: 3). Can target a specific element by label or ID with the 'on' parameter.",
|
|
1792
1759
|
{
|
|
1793
|
-
direction:
|
|
1794
|
-
ticks:
|
|
1795
|
-
on:
|
|
1796
|
-
app:
|
|
1760
|
+
direction: z5.enum(["up", "down", "left", "right"]).describe("Scroll direction"),
|
|
1761
|
+
ticks: z5.number().optional().default(3).describe("Number of scroll ticks (default: 3). Higher = more scrolling."),
|
|
1762
|
+
on: z5.string().optional().describe("Element label or ID to scroll within (from desktop_see). Omit to scroll the active area."),
|
|
1763
|
+
app: z5.string().optional().describe("App name to target")
|
|
1797
1764
|
},
|
|
1798
1765
|
async ({ direction, ticks, on, app }) => {
|
|
1799
1766
|
checkBlacklist(app);
|
|
@@ -1812,7 +1779,7 @@ var DesktopTools = class {
|
|
|
1812
1779
|
{},
|
|
1813
1780
|
async () => {
|
|
1814
1781
|
try {
|
|
1815
|
-
const { stdout } = await
|
|
1782
|
+
const { stdout } = await execa("peekaboo", ["list", "apps", "--json"]);
|
|
1816
1783
|
return {
|
|
1817
1784
|
content: [{ type: "text", text: stdout }]
|
|
1818
1785
|
};
|
|
@@ -1826,21 +1793,21 @@ var DesktopTools = class {
|
|
|
1826
1793
|
"desktop_list_windows",
|
|
1827
1794
|
"List all open windows on macOS, optionally filtered by app name. If no app is specified, lists windows for the frontmost application. Useful for identifying which windows are available for automation.",
|
|
1828
1795
|
{
|
|
1829
|
-
app:
|
|
1796
|
+
app: z5.string().optional().describe("Filter by app name. Omit to query the frontmost app.")
|
|
1830
1797
|
},
|
|
1831
1798
|
async ({ app }) => {
|
|
1832
1799
|
checkBlacklist(app);
|
|
1833
1800
|
try {
|
|
1834
1801
|
let targetApp = app;
|
|
1835
1802
|
if (!targetApp) {
|
|
1836
|
-
const { stdout: stdout2 } = await
|
|
1803
|
+
const { stdout: stdout2 } = await execa("osascript", [
|
|
1837
1804
|
"-e",
|
|
1838
1805
|
'tell application "System Events" to get name of first application process whose frontmost is true'
|
|
1839
1806
|
]);
|
|
1840
1807
|
targetApp = stdout2.trim();
|
|
1841
1808
|
}
|
|
1842
1809
|
const args = ["list", "windows", "--app", targetApp, "--json"];
|
|
1843
|
-
const { stdout } = await
|
|
1810
|
+
const { stdout } = await execa("peekaboo", args);
|
|
1844
1811
|
return {
|
|
1845
1812
|
content: [{ type: "text", text: stdout }]
|
|
1846
1813
|
};
|
|
@@ -1859,8 +1826,8 @@ var DesktopTools = class {
|
|
|
1859
1826
|
"Prefer desktop_see (Accessibility Tree) for understanding UI structure \u2014 use screenshot only when visual appearance matters (layouts, images, colors)."
|
|
1860
1827
|
].join("\n"),
|
|
1861
1828
|
{
|
|
1862
|
-
app:
|
|
1863
|
-
mode:
|
|
1829
|
+
app: z5.string().optional().describe("Capture a specific app's window (by name)"),
|
|
1830
|
+
mode: z5.enum(["screen", "window"]).optional().default("screen").describe("'screen': full display capture, 'window': specific app window only")
|
|
1864
1831
|
},
|
|
1865
1832
|
async ({ app, mode }) => {
|
|
1866
1833
|
checkBlacklist(app);
|
|
@@ -1871,7 +1838,7 @@ var DesktopTools = class {
|
|
|
1871
1838
|
const files = data?.files;
|
|
1872
1839
|
const filePath = files?.[0]?.path;
|
|
1873
1840
|
if (filePath) {
|
|
1874
|
-
const imageBuffer =
|
|
1841
|
+
const imageBuffer = fs5.readFileSync(filePath);
|
|
1875
1842
|
return {
|
|
1876
1843
|
content: [{
|
|
1877
1844
|
type: "image",
|
|
@@ -1894,15 +1861,15 @@ var DesktopTools = class {
|
|
|
1894
1861
|
"The target app must be running and accessible."
|
|
1895
1862
|
].join("\n"),
|
|
1896
1863
|
{
|
|
1897
|
-
path:
|
|
1898
|
-
app:
|
|
1864
|
+
path: z5.array(z5.string()).describe("Menu path as array (e.g. ['File', 'Save'], ['Edit', 'Find', 'Find...'])"),
|
|
1865
|
+
app: z5.string().optional().describe("App name to target. Omit for the frontmost app.")
|
|
1899
1866
|
},
|
|
1900
1867
|
async ({ path: path4, app }) => {
|
|
1901
1868
|
checkBlacklist(app);
|
|
1902
1869
|
const args = ["menu", "click", "--path", path4.join(" > ")];
|
|
1903
1870
|
if (app) args.push("--app", app);
|
|
1904
1871
|
try {
|
|
1905
|
-
const { stdout } = await
|
|
1872
|
+
const { stdout } = await execa("peekaboo", args);
|
|
1906
1873
|
return {
|
|
1907
1874
|
content: [{ type: "text", text: stdout || "Menu click executed" }]
|
|
1908
1875
|
};
|
|
@@ -1920,7 +1887,7 @@ var mcpPort = 3e3;
|
|
|
1920
1887
|
var globalBrowserTools = null;
|
|
1921
1888
|
var desktopToolsEnabled = false;
|
|
1922
1889
|
function createMcpServer() {
|
|
1923
|
-
const server = new
|
|
1890
|
+
const server = new McpServer({
|
|
1924
1891
|
name: "junis",
|
|
1925
1892
|
version: "0.1.0"
|
|
1926
1893
|
});
|
|
@@ -2050,7 +2017,7 @@ async function startMCPServer(port) {
|
|
|
2050
2017
|
console.log("\u2705 Peekaboo available \u2014 desktop tools enabled");
|
|
2051
2018
|
}
|
|
2052
2019
|
let resolvedPort = port;
|
|
2053
|
-
const httpServer =
|
|
2020
|
+
const httpServer = createServer(
|
|
2054
2021
|
async (req, res) => {
|
|
2055
2022
|
try {
|
|
2056
2023
|
if (req.method === "OPTIONS") {
|
|
@@ -2064,7 +2031,7 @@ async function startMCPServer(port) {
|
|
|
2064
2031
|
if (url === "/mcp") {
|
|
2065
2032
|
if (req.method === "POST") {
|
|
2066
2033
|
const mcpServer = createMcpServer();
|
|
2067
|
-
const transport = new
|
|
2034
|
+
const transport = new StreamableHTTPServerTransport({
|
|
2068
2035
|
sessionIdGenerator: void 0
|
|
2069
2036
|
// stateless
|
|
2070
2037
|
});
|
|
@@ -2215,10 +2182,11 @@ async function handleMCPRequest(id, payload) {
|
|
|
2215
2182
|
}
|
|
2216
2183
|
|
|
2217
2184
|
// src/server/stdio.ts
|
|
2218
|
-
|
|
2219
|
-
|
|
2185
|
+
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2186
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2187
|
+
import { fileURLToPath } from "url";
|
|
2220
2188
|
async function startStdioServer() {
|
|
2221
|
-
const server = new
|
|
2189
|
+
const server = new McpServer2({ name: "junis", version: "0.1.0" });
|
|
2222
2190
|
const fsTools = new FilesystemTools();
|
|
2223
2191
|
fsTools.register(server);
|
|
2224
2192
|
const browserTools = new BrowserTools();
|
|
@@ -2228,60 +2196,64 @@ async function startStdioServer() {
|
|
|
2228
2196
|
notebookTools.register(server);
|
|
2229
2197
|
const deviceTools = new DeviceTools();
|
|
2230
2198
|
deviceTools.register(server);
|
|
2231
|
-
const transport = new
|
|
2199
|
+
const transport = new StdioServerTransport();
|
|
2232
2200
|
await server.connect(transport);
|
|
2233
2201
|
process.on("SIGINT", async () => {
|
|
2234
2202
|
await browserTools.cleanup();
|
|
2235
2203
|
process.exit(0);
|
|
2236
2204
|
});
|
|
2237
2205
|
}
|
|
2238
|
-
if (
|
|
2206
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
2239
2207
|
startStdioServer().catch(console.error);
|
|
2240
2208
|
}
|
|
2241
2209
|
|
|
2210
|
+
// src/cli/index.ts
|
|
2211
|
+
import { execSync as execSync2 } from "child_process";
|
|
2212
|
+
import { createRequire } from "module";
|
|
2213
|
+
|
|
2242
2214
|
// src/cli/daemon.ts
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
var CONFIG_DIR2 =
|
|
2248
|
-
var PID_FILE =
|
|
2249
|
-
var LOG_DIR =
|
|
2250
|
-
var LOG_FILE =
|
|
2251
|
-
var PLIST_PATH =
|
|
2252
|
-
|
|
2215
|
+
import fs6 from "fs";
|
|
2216
|
+
import path3 from "path";
|
|
2217
|
+
import os2 from "os";
|
|
2218
|
+
import { execSync, spawn } from "child_process";
|
|
2219
|
+
var CONFIG_DIR2 = path3.join(os2.homedir(), ".junis");
|
|
2220
|
+
var PID_FILE = path3.join(CONFIG_DIR2, "junis.pid");
|
|
2221
|
+
var LOG_DIR = path3.join(CONFIG_DIR2, "logs");
|
|
2222
|
+
var LOG_FILE = path3.join(LOG_DIR, "junis.log");
|
|
2223
|
+
var PLIST_PATH = path3.join(
|
|
2224
|
+
os2.homedir(),
|
|
2253
2225
|
"Library/LaunchAgents/ai.junis.plist"
|
|
2254
2226
|
);
|
|
2255
|
-
var SYSTEMD_PATH =
|
|
2256
|
-
|
|
2227
|
+
var SYSTEMD_PATH = path3.join(
|
|
2228
|
+
os2.homedir(),
|
|
2257
2229
|
".config/systemd/user/junis.service"
|
|
2258
2230
|
);
|
|
2259
2231
|
function isRunning() {
|
|
2260
2232
|
try {
|
|
2261
|
-
if (!
|
|
2262
|
-
const pid = parseInt(
|
|
2233
|
+
if (!fs6.existsSync(PID_FILE)) return { running: false };
|
|
2234
|
+
const pid = parseInt(fs6.readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
2263
2235
|
if (isNaN(pid)) return { running: false };
|
|
2264
2236
|
process.kill(pid, 0);
|
|
2265
2237
|
return { running: true, pid };
|
|
2266
2238
|
} catch {
|
|
2267
2239
|
try {
|
|
2268
|
-
|
|
2240
|
+
fs6.unlinkSync(PID_FILE);
|
|
2269
2241
|
} catch {
|
|
2270
2242
|
}
|
|
2271
2243
|
return { running: false };
|
|
2272
2244
|
}
|
|
2273
2245
|
}
|
|
2274
2246
|
function writePid(pid) {
|
|
2275
|
-
|
|
2276
|
-
|
|
2247
|
+
fs6.mkdirSync(CONFIG_DIR2, { recursive: true });
|
|
2248
|
+
fs6.writeFileSync(PID_FILE, String(pid), "utf-8");
|
|
2277
2249
|
}
|
|
2278
2250
|
function startDaemon(port) {
|
|
2279
|
-
|
|
2251
|
+
fs6.mkdirSync(LOG_DIR, { recursive: true });
|
|
2280
2252
|
const nodePath = process.execPath;
|
|
2281
2253
|
const scriptPath = process.argv[1];
|
|
2282
|
-
const out =
|
|
2283
|
-
const err =
|
|
2284
|
-
const child =
|
|
2254
|
+
const out = fs6.openSync(LOG_FILE, "a");
|
|
2255
|
+
const err = fs6.openSync(LOG_FILE, "a");
|
|
2256
|
+
const child = spawn(nodePath, [scriptPath, "start", "--daemon", "--port", String(port)], {
|
|
2285
2257
|
detached: true,
|
|
2286
2258
|
stdio: ["ignore", out, err],
|
|
2287
2259
|
env: { ...process.env }
|
|
@@ -2297,7 +2269,7 @@ function stopDaemon() {
|
|
|
2297
2269
|
try {
|
|
2298
2270
|
process.kill(pid, "SIGTERM");
|
|
2299
2271
|
try {
|
|
2300
|
-
|
|
2272
|
+
fs6.unlinkSync(PID_FILE);
|
|
2301
2273
|
} catch {
|
|
2302
2274
|
}
|
|
2303
2275
|
return true;
|
|
@@ -2331,7 +2303,7 @@ var ServiceManager = class {
|
|
|
2331
2303
|
<key>EnvironmentVariables</key>
|
|
2332
2304
|
<dict>
|
|
2333
2305
|
<key>HOME</key>
|
|
2334
|
-
<string>${
|
|
2306
|
+
<string>${os2.homedir()}</string>
|
|
2335
2307
|
<key>PATH</key>
|
|
2336
2308
|
<string>${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}</string>
|
|
2337
2309
|
${process.env.JUNIS_API_URL ? `<key>JUNIS_API_URL</key>
|
|
@@ -2351,12 +2323,12 @@ var ServiceManager = class {
|
|
|
2351
2323
|
<string>${LOG_FILE}</string>
|
|
2352
2324
|
</dict>
|
|
2353
2325
|
</plist>`;
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2326
|
+
fs6.mkdirSync(path3.dirname(PLIST_PATH), { recursive: true });
|
|
2327
|
+
fs6.mkdirSync(LOG_DIR, { recursive: true });
|
|
2328
|
+
fs6.writeFileSync(PLIST_PATH, plist, "utf-8");
|
|
2357
2329
|
try {
|
|
2358
|
-
|
|
2359
|
-
|
|
2330
|
+
execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null || true`);
|
|
2331
|
+
execSync(`launchctl load "${PLIST_PATH}"`);
|
|
2360
2332
|
} catch (e) {
|
|
2361
2333
|
throw new Error(`launchctl load failed: ${e.message}`);
|
|
2362
2334
|
}
|
|
@@ -2369,7 +2341,7 @@ After=network.target
|
|
|
2369
2341
|
ExecStart=${nodePath} ${scriptPath} start --daemon
|
|
2370
2342
|
Restart=always
|
|
2371
2343
|
RestartSec=5
|
|
2372
|
-
Environment=HOME=${
|
|
2344
|
+
Environment=HOME=${os2.homedir()}
|
|
2373
2345
|
Environment=PATH=${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}
|
|
2374
2346
|
${process.env.JUNIS_API_URL ? `Environment=JUNIS_API_URL=${process.env.JUNIS_API_URL}` : ""}
|
|
2375
2347
|
${process.env.JUNIS_WS_URL ? `Environment=JUNIS_WS_URL=${process.env.JUNIS_WS_URL}` : ""}
|
|
@@ -2379,14 +2351,14 @@ StandardError=append:${LOG_FILE}
|
|
|
2379
2351
|
|
|
2380
2352
|
[Install]
|
|
2381
2353
|
WantedBy=default.target`;
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2354
|
+
fs6.mkdirSync(path3.dirname(SYSTEMD_PATH), { recursive: true });
|
|
2355
|
+
fs6.mkdirSync(LOG_DIR, { recursive: true });
|
|
2356
|
+
fs6.writeFileSync(SYSTEMD_PATH, unit, "utf-8");
|
|
2357
|
+
execSync("systemctl --user daemon-reload");
|
|
2358
|
+
execSync("systemctl --user enable junis");
|
|
2359
|
+
execSync("systemctl --user start junis");
|
|
2388
2360
|
} else {
|
|
2389
|
-
|
|
2361
|
+
execSync(
|
|
2390
2362
|
`schtasks /Create /F /TN "Junis" /TR "${nodePath} ${scriptPath} start --daemon" /SC ONLOGON /RL HIGHEST`
|
|
2391
2363
|
);
|
|
2392
2364
|
}
|
|
@@ -2394,21 +2366,21 @@ WantedBy=default.target`;
|
|
|
2394
2366
|
async uninstall() {
|
|
2395
2367
|
if (this.platform === "mac") {
|
|
2396
2368
|
try {
|
|
2397
|
-
|
|
2398
|
-
if (
|
|
2369
|
+
execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null || true`);
|
|
2370
|
+
if (fs6.existsSync(PLIST_PATH)) fs6.unlinkSync(PLIST_PATH);
|
|
2399
2371
|
} catch {
|
|
2400
2372
|
}
|
|
2401
2373
|
} else if (this.platform === "linux") {
|
|
2402
2374
|
try {
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
if (
|
|
2406
|
-
|
|
2375
|
+
execSync("systemctl --user stop junis 2>/dev/null || true");
|
|
2376
|
+
execSync("systemctl --user disable junis 2>/dev/null || true");
|
|
2377
|
+
if (fs6.existsSync(SYSTEMD_PATH)) fs6.unlinkSync(SYSTEMD_PATH);
|
|
2378
|
+
execSync("systemctl --user daemon-reload 2>/dev/null || true");
|
|
2407
2379
|
} catch {
|
|
2408
2380
|
}
|
|
2409
2381
|
} else {
|
|
2410
2382
|
try {
|
|
2411
|
-
|
|
2383
|
+
execSync('schtasks /Delete /F /TN "Junis" 2>nul || true');
|
|
2412
2384
|
} catch {
|
|
2413
2385
|
}
|
|
2414
2386
|
}
|
|
@@ -2416,13 +2388,13 @@ WantedBy=default.target`;
|
|
|
2416
2388
|
};
|
|
2417
2389
|
|
|
2418
2390
|
// src/cli/index.ts
|
|
2419
|
-
var
|
|
2420
|
-
|
|
2391
|
+
var _require = createRequire(import.meta.url);
|
|
2392
|
+
var { version } = _require("../../package.json");
|
|
2393
|
+
program.name("junis").description("MCP server for full device control by AI").version(version);
|
|
2421
2394
|
function getSystemInfo() {
|
|
2422
2395
|
const platform3 = process.platform;
|
|
2423
2396
|
if (platform3 === "darwin") {
|
|
2424
2397
|
try {
|
|
2425
|
-
const { execSync: execSync2 } = require("child_process");
|
|
2426
2398
|
const sw = execSync2("sw_vers -productVersion", { encoding: "utf8" }).trim();
|
|
2427
2399
|
const hw = execSync2("sysctl -n machdep.cpu.brand_string", { encoding: "utf8" }).trim();
|
|
2428
2400
|
return `macOS ${sw} (${hw})`;
|
|
@@ -2466,6 +2438,27 @@ function printStep1(port) {
|
|
|
2466
2438
|
console.log(` \u25C9 Local MCP endpoint ........... http://localhost:${port}/mcp`);
|
|
2467
2439
|
console.log("");
|
|
2468
2440
|
}
|
|
2441
|
+
async function checkEnsureTeam(config, label) {
|
|
2442
|
+
try {
|
|
2443
|
+
const result = await ensureTeam(config.device_key, config.token);
|
|
2444
|
+
if (result.status === "ready") {
|
|
2445
|
+
return config;
|
|
2446
|
+
}
|
|
2447
|
+
if (result.status === "created") {
|
|
2448
|
+
console.log(`[${label}] Team restored automatically`);
|
|
2449
|
+
if (result.token) {
|
|
2450
|
+
config.token = result.token;
|
|
2451
|
+
saveConfig(config);
|
|
2452
|
+
}
|
|
2453
|
+
return config;
|
|
2454
|
+
}
|
|
2455
|
+
console.log(`[${label}] ${result.status} \u2014 re-authentication required`);
|
|
2456
|
+
clearConfig();
|
|
2457
|
+
return null;
|
|
2458
|
+
} catch {
|
|
2459
|
+
return config;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2469
2462
|
async function runForeground(config, port) {
|
|
2470
2463
|
const deviceName = config.device_name;
|
|
2471
2464
|
const platformName = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux";
|
|
@@ -2553,7 +2546,7 @@ async function runBackground(config, port) {
|
|
|
2553
2546
|
console.log("");
|
|
2554
2547
|
process.exit(0);
|
|
2555
2548
|
}
|
|
2556
|
-
|
|
2549
|
+
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) => {
|
|
2557
2550
|
const port = parseInt(options.port, 10);
|
|
2558
2551
|
if (options.stdio) {
|
|
2559
2552
|
await startStdioServer();
|
|
@@ -2620,6 +2613,39 @@ import_commander.program.command("start", { isDefault: true }).description("Star
|
|
|
2620
2613
|
console.log("");
|
|
2621
2614
|
}
|
|
2622
2615
|
} else {
|
|
2616
|
+
const checked = await checkEnsureTeam(config2, "junis");
|
|
2617
|
+
if (!checked) {
|
|
2618
|
+
let waitingPrinted = false;
|
|
2619
|
+
const authResult = await authenticate(
|
|
2620
|
+
deviceName2,
|
|
2621
|
+
platformName2,
|
|
2622
|
+
(uri) => {
|
|
2623
|
+
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");
|
|
2624
|
+
console.log(" STEP 2 \xB7 Connect to Junis Cloud");
|
|
2625
|
+
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");
|
|
2626
|
+
console.log(" Opening browser...");
|
|
2627
|
+
console.log(` \u2192 ${uri}`);
|
|
2628
|
+
process.stdout.write(" Waiting for login \xB7");
|
|
2629
|
+
},
|
|
2630
|
+
() => {
|
|
2631
|
+
if (!waitingPrinted) {
|
|
2632
|
+
waitingPrinted = true;
|
|
2633
|
+
} else {
|
|
2634
|
+
process.stdout.write("\xB7");
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
);
|
|
2638
|
+
console.log("");
|
|
2639
|
+
console.log(` \u2705 Authenticated as ${authResult.email ?? "your account"}`);
|
|
2640
|
+
console.log("");
|
|
2641
|
+
config2 = {
|
|
2642
|
+
device_key: authResult.device_key,
|
|
2643
|
+
token: authResult.token,
|
|
2644
|
+
device_name: deviceName2,
|
|
2645
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2646
|
+
};
|
|
2647
|
+
saveConfig(config2);
|
|
2648
|
+
}
|
|
2623
2649
|
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");
|
|
2624
2650
|
console.log(" STEP 3 \xB7 Register Device");
|
|
2625
2651
|
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");
|
|
@@ -2644,6 +2670,12 @@ import_commander.program.command("start", { isDefault: true }).description("Star
|
|
|
2644
2670
|
console.error("\u274C No credentials found. Run npx junis first.");
|
|
2645
2671
|
process.exit(1);
|
|
2646
2672
|
}
|
|
2673
|
+
const checked = await checkEnsureTeam(config2, "junis daemon");
|
|
2674
|
+
if (!checked) {
|
|
2675
|
+
console.error("\u274C Device or team invalid. Run npx junis --reset to re-authenticate.");
|
|
2676
|
+
process.exit(1);
|
|
2677
|
+
}
|
|
2678
|
+
config2 = checked;
|
|
2647
2679
|
const deviceName2 = config2.device_name;
|
|
2648
2680
|
const platformName2 = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux";
|
|
2649
2681
|
const actualPort = await startMCPServer(port);
|
|
@@ -2693,7 +2725,7 @@ import_commander.program.command("start", { isDefault: true }).description("Star
|
|
|
2693
2725
|
console.log(" To stop: npx junis stop");
|
|
2694
2726
|
return;
|
|
2695
2727
|
}
|
|
2696
|
-
const mode = await
|
|
2728
|
+
const mode = await select({
|
|
2697
2729
|
message: "Select run mode:",
|
|
2698
2730
|
choices: [
|
|
2699
2731
|
{
|
|
@@ -2767,6 +2799,39 @@ import_commander.program.command("start", { isDefault: true }).description("Star
|
|
|
2767
2799
|
console.log("");
|
|
2768
2800
|
}
|
|
2769
2801
|
} else {
|
|
2802
|
+
const checked = await checkEnsureTeam(config, "junis");
|
|
2803
|
+
if (!checked) {
|
|
2804
|
+
let waitingPrinted = false;
|
|
2805
|
+
const authResult = await authenticate(
|
|
2806
|
+
deviceName,
|
|
2807
|
+
platformName,
|
|
2808
|
+
(uri) => {
|
|
2809
|
+
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");
|
|
2810
|
+
console.log(" STEP 2 \xB7 Connect to Junis Cloud");
|
|
2811
|
+
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");
|
|
2812
|
+
console.log(" Opening browser...");
|
|
2813
|
+
console.log(` \u2192 ${uri}`);
|
|
2814
|
+
process.stdout.write(" Waiting for login \xB7");
|
|
2815
|
+
},
|
|
2816
|
+
() => {
|
|
2817
|
+
if (!waitingPrinted) {
|
|
2818
|
+
waitingPrinted = true;
|
|
2819
|
+
} else {
|
|
2820
|
+
process.stdout.write("\xB7");
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
);
|
|
2824
|
+
console.log("");
|
|
2825
|
+
console.log(` \u2705 Authenticated as ${authResult.email ?? "your account"}`);
|
|
2826
|
+
console.log("");
|
|
2827
|
+
config = {
|
|
2828
|
+
device_key: authResult.device_key,
|
|
2829
|
+
token: authResult.token,
|
|
2830
|
+
device_name: deviceName,
|
|
2831
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2832
|
+
};
|
|
2833
|
+
saveConfig(config);
|
|
2834
|
+
}
|
|
2770
2835
|
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");
|
|
2771
2836
|
console.log(" STEP 3 \xB7 Register Device");
|
|
2772
2837
|
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");
|
|
@@ -2782,7 +2847,7 @@ import_commander.program.command("start", { isDefault: true }).description("Star
|
|
|
2782
2847
|
await runBackground(config, port);
|
|
2783
2848
|
}
|
|
2784
2849
|
});
|
|
2785
|
-
|
|
2850
|
+
program.command("stop").description("Stop background service and disable auto-start").action(async () => {
|
|
2786
2851
|
const stopped = stopDaemon();
|
|
2787
2852
|
const svc = new ServiceManager();
|
|
2788
2853
|
let serviceUninstalled = false;
|
|
@@ -2798,11 +2863,11 @@ import_commander.program.command("stop").description("Stop background service an
|
|
|
2798
2863
|
console.log("\u2139\uFE0F No running Junis process found.");
|
|
2799
2864
|
}
|
|
2800
2865
|
});
|
|
2801
|
-
|
|
2866
|
+
program.command("logout").description("Clear authentication credentials").action(() => {
|
|
2802
2867
|
clearConfig();
|
|
2803
2868
|
console.log("\u2705 Authentication credentials cleared");
|
|
2804
2869
|
});
|
|
2805
|
-
|
|
2870
|
+
program.command("status").description("Check current status").action(() => {
|
|
2806
2871
|
const config = loadConfig();
|
|
2807
2872
|
const { running, pid } = isRunning();
|
|
2808
2873
|
if (!config) {
|
|
@@ -2817,7 +2882,7 @@ import_commander.program.command("status").description("Check current status").a
|
|
|
2817
2882
|
console.log(" To start: npx junis");
|
|
2818
2883
|
}
|
|
2819
2884
|
});
|
|
2820
|
-
|
|
2885
|
+
program.addHelpText("after", `
|
|
2821
2886
|
Examples:
|
|
2822
2887
|
npx junis Interactive mode (foreground/background)
|
|
2823
2888
|
npx junis --local Local MCP server only (no cloud)
|
|
@@ -2835,4 +2900,4 @@ MCP Client Config (Claude Code, Claude Desktop, Codex, etc.):
|
|
|
2835
2900
|
}
|
|
2836
2901
|
}
|
|
2837
2902
|
`);
|
|
2838
|
-
|
|
2903
|
+
program.parse();
|