junis 0.2.6 → 0.3.2

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