junis 0.2.6 → 0.3.1

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