junis 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "junis",
34
- version: "0.2.0",
34
+ version: "0.2.2",
35
35
  description: "One-line device control for AI agents",
36
36
  bin: {
37
37
  junis: "dist/cli/index.js"
@@ -87,8 +87,12 @@ function loadConfig() {
87
87
  }
88
88
  }
89
89
  function saveConfig(config) {
90
- import_fs.default.mkdirSync(CONFIG_DIR, { recursive: true });
91
- import_fs.default.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
90
+ try {
91
+ import_fs.default.mkdirSync(CONFIG_DIR, { recursive: true });
92
+ import_fs.default.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
93
+ } catch (err) {
94
+ throw new Error(`\uC778\uC99D \uC815\uBCF4 \uC800\uC7A5 \uC2E4\uD328: ${err.message}`);
95
+ }
92
96
  }
93
97
  function clearConfig() {
94
98
  if (import_fs.default.existsSync(CONFIG_FILE)) {
@@ -276,6 +280,7 @@ var import_path2 = __toESM(require("path"));
276
280
  var import_glob = require("glob");
277
281
  var import_zod = require("zod");
278
282
  var execAsync = (0, import_util.promisify)(import_child_process.exec);
283
+ var execFileAsync = (0, import_util.promisify)(import_child_process.execFile);
279
284
  var FilesystemTools = class {
280
285
  register(server) {
281
286
  server.tool(
@@ -360,8 +365,9 @@ ${error.stderr ?? ""}`
360
365
  },
361
366
  async ({ pattern, directory, file_pattern }) => {
362
367
  try {
363
- const { stdout } = await execAsync(
364
- `rg --no-heading -n "${pattern}" ${directory}`,
368
+ const { stdout } = await execFileAsync(
369
+ "rg",
370
+ ["--no-heading", "-n", pattern, directory],
365
371
  { timeout: 1e4 }
366
372
  );
367
373
  return { content: [{ type: "text", text: stdout || "\uACB0\uACFC \uC5C6\uC74C" }] };
@@ -498,6 +504,17 @@ var import_zod2 = require("zod");
498
504
  var BrowserTools = class {
499
505
  browser = null;
500
506
  page = null;
507
+ // 동시 요청 시 race condition 방지용 직렬화 락
508
+ lock = Promise.resolve();
509
+ withLock(fn) {
510
+ let release;
511
+ const next = new Promise((r) => {
512
+ release = r;
513
+ });
514
+ const current = this.lock;
515
+ this.lock = this.lock.then(() => next);
516
+ return current.then(() => fn()).finally(() => release());
517
+ }
501
518
  async init() {
502
519
  try {
503
520
  this.browser = await import_playwright.chromium.launch({ headless: true });
@@ -520,22 +537,22 @@ var BrowserTools = class {
520
537
  "browser_navigate",
521
538
  "URL\uB85C \uC774\uB3D9",
522
539
  { url: import_zod2.z.string().describe("\uC774\uB3D9\uD560 URL") },
523
- async ({ url }) => {
540
+ ({ url }) => this.withLock(async () => {
524
541
  const page = requirePage();
525
542
  await page.goto(url, { waitUntil: "domcontentloaded" });
526
543
  return {
527
544
  content: [{ type: "text", text: `\uC774\uB3D9 \uC644\uB8CC: ${page.url()}` }]
528
545
  };
529
- }
546
+ })
530
547
  );
531
548
  server.tool(
532
549
  "browser_click",
533
550
  "\uC694\uC18C \uD074\uB9AD",
534
551
  { selector: import_zod2.z.string().describe("CSS \uC120\uD0DD\uC790") },
535
- async ({ selector }) => {
552
+ ({ selector }) => this.withLock(async () => {
536
553
  await requirePage().click(selector);
537
554
  return { content: [{ type: "text", text: "\uD074\uB9AD \uC644\uB8CC" }] };
538
- }
555
+ })
539
556
  );
540
557
  server.tool(
541
558
  "browser_type",
@@ -545,12 +562,12 @@ var BrowserTools = class {
545
562
  text: import_zod2.z.string().describe("\uC785\uB825\uD560 \uD14D\uC2A4\uD2B8"),
546
563
  clear: import_zod2.z.boolean().optional().default(false).describe("\uAE30\uC874 \uB0B4\uC6A9 \uC0AD\uC81C \uD6C4 \uC785\uB825")
547
564
  },
548
- async ({ selector, text, clear }) => {
565
+ ({ selector, text, clear }) => this.withLock(async () => {
549
566
  const page = requirePage();
550
567
  if (clear) await page.fill(selector, text);
551
568
  else await page.type(selector, text);
552
569
  return { content: [{ type: "text", text: "\uC785\uB825 \uC644\uB8CC" }] };
553
- }
570
+ })
554
571
  );
555
572
  server.tool(
556
573
  "browser_screenshot",
@@ -559,7 +576,7 @@ var BrowserTools = class {
559
576
  path: import_zod2.z.string().optional().describe("\uC800\uC7A5 \uACBD\uB85C (\uC5C6\uC73C\uBA74 base64 \uBC18\uD658)"),
560
577
  full_page: import_zod2.z.boolean().optional().default(false)
561
578
  },
562
- async ({ path: path3, full_page }) => {
579
+ ({ path: path3, full_page }) => this.withLock(async () => {
563
580
  const page = requirePage();
564
581
  const screenshot = await page.screenshot({
565
582
  path: path3 ?? void 0,
@@ -577,13 +594,13 @@ var BrowserTools = class {
577
594
  }
578
595
  ]
579
596
  };
580
- }
597
+ })
581
598
  );
582
599
  server.tool(
583
600
  "browser_snapshot",
584
601
  "\uD398\uC774\uC9C0 \uC811\uADFC\uC131 \uD2B8\uB9AC \uC870\uD68C (\uAD6C\uC870 \uD30C\uC545\uC6A9)",
585
602
  {},
586
- async () => {
603
+ () => this.withLock(async () => {
587
604
  const page = requirePage();
588
605
  const snapshot = await page.locator("body").ariaSnapshot();
589
606
  return {
@@ -591,29 +608,29 @@ var BrowserTools = class {
591
608
  { type: "text", text: snapshot }
592
609
  ]
593
610
  };
594
- }
611
+ })
595
612
  );
596
613
  server.tool(
597
614
  "browser_evaluate",
598
615
  "JavaScript \uC2E4\uD589",
599
616
  { code: import_zod2.z.string().describe("\uC2E4\uD589\uD560 JavaScript \uCF54\uB4DC") },
600
- async ({ code }) => {
617
+ ({ code }) => this.withLock(async () => {
601
618
  const result = await requirePage().evaluate(code);
602
619
  return {
603
620
  content: [
604
621
  { type: "text", text: JSON.stringify(result, null, 2) }
605
622
  ]
606
623
  };
607
- }
624
+ })
608
625
  );
609
626
  server.tool(
610
627
  "browser_pdf",
611
628
  "\uD604\uC7AC \uD398\uC774\uC9C0 PDF \uC800\uC7A5",
612
629
  { path: import_zod2.z.string().describe("\uC800\uC7A5 \uACBD\uB85C (.pdf)") },
613
- async ({ path: path3 }) => {
630
+ ({ path: path3 }) => this.withLock(async () => {
614
631
  await requirePage().pdf({ path: path3 });
615
632
  return { content: [{ type: "text", text: `PDF \uC800\uC7A5 \uC644\uB8CC: ${path3}` }] };
616
- }
633
+ })
617
634
  );
618
635
  }
619
636
  };
@@ -626,7 +643,11 @@ var import_util2 = require("util");
626
643
  var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
627
644
  async function readNotebook(filePath) {
628
645
  const raw = await import_promises2.default.readFile(filePath, "utf-8");
629
- return JSON.parse(raw);
646
+ try {
647
+ return JSON.parse(raw);
648
+ } catch {
649
+ throw new Error(`\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 .ipynb \uD30C\uC77C: ${filePath}`);
650
+ }
630
651
  }
631
652
  async function writeNotebook(filePath, nb) {
632
653
  await import_promises2.default.writeFile(filePath, JSON.stringify(nb, null, 1), "utf-8");
@@ -725,8 +746,9 @@ var NotebookTools = class {
725
746
  nb.cells.push(newCell);
726
747
  actualIndex = nb.cells.length - 1;
727
748
  } else {
728
- nb.cells.splice(position, 0, newCell);
729
- actualIndex = position;
749
+ const clamped = Math.max(0, Math.min(position, nb.cells.length));
750
+ nb.cells.splice(clamped, 0, newCell);
751
+ actualIndex = clamped;
730
752
  }
731
753
  await writeNotebook(filePath, nb);
732
754
  return { content: [{ type: "text", text: `\uC140 \uCD94\uAC00 \uC644\uB8CC (index: ${actualIndex})` }] };
@@ -757,13 +779,13 @@ var import_child_process3 = require("child_process");
757
779
  var import_util3 = require("util");
758
780
  var import_zod4 = require("zod");
759
781
  var execAsync3 = (0, import_util3.promisify)(import_child_process3.exec);
782
+ var screenRecordPid = null;
760
783
  function platform() {
761
784
  if (process.platform === "darwin") return "mac";
762
785
  if (process.platform === "win32") return "win";
763
786
  return "linux";
764
787
  }
765
788
  var DeviceTools = class {
766
- screenRecordPid = null;
767
789
  register(server) {
768
790
  server.tool(
769
791
  "screen_capture",
@@ -773,15 +795,24 @@ var DeviceTools = class {
773
795
  },
774
796
  async ({ output_path }) => {
775
797
  const p = platform();
798
+ const isTmp = !output_path;
776
799
  const tmpPath = output_path ?? `/tmp/junis_screen_${Date.now()}.png`;
777
800
  const cmd = {
778
801
  mac: `screencapture -x "${tmpPath}"`,
779
802
  win: `nircmd.exe savescreenshot "${tmpPath}"`,
780
803
  linux: `scrot "${tmpPath}"`
781
804
  }[p];
782
- await execAsync3(cmd);
783
- const { readFileSync } = await import("fs");
805
+ try {
806
+ await execAsync3(cmd);
807
+ } catch (err) {
808
+ throw new Error(`\uD654\uBA74 \uCEA1\uCC98 \uC2E4\uD328: ${err.message}`);
809
+ }
810
+ const { readFileSync, unlinkSync } = await import("fs");
784
811
  const data = readFileSync(tmpPath).toString("base64");
812
+ if (isTmp) try {
813
+ unlinkSync(tmpPath);
814
+ } catch {
815
+ }
785
816
  return {
786
817
  content: [{ type: "image", data, mimeType: "image/png" }]
787
818
  };
@@ -795,15 +826,24 @@ var DeviceTools = class {
795
826
  },
796
827
  async ({ output_path }) => {
797
828
  const p = platform();
829
+ const isTmp = !output_path;
798
830
  const tmpPath = output_path ?? `/tmp/junis_cam_${Date.now()}.jpg`;
799
831
  const cmd = {
800
832
  mac: `imagesnap "${tmpPath}"`,
801
833
  win: `ffmpeg -f dshow -i video="Default" -frames:v 1 "${tmpPath}"`,
802
834
  linux: `fswebcam -r 1280x720 "${tmpPath}"`
803
835
  }[p];
804
- await execAsync3(cmd);
805
- const { readFileSync } = await import("fs");
836
+ try {
837
+ await execAsync3(cmd);
838
+ } catch (err) {
839
+ throw new Error(`\uCE74\uBA54\uB77C \uCD2C\uC601 \uC2E4\uD328: ${err.message}`);
840
+ }
841
+ const { readFileSync, unlinkSync } = await import("fs");
806
842
  const data = readFileSync(tmpPath).toString("base64");
843
+ if (isTmp) try {
844
+ unlinkSync(tmpPath);
845
+ } catch {
846
+ }
807
847
  return {
808
848
  content: [{ type: "image", data, mimeType: "image/jpeg" }]
809
849
  };
@@ -863,7 +903,7 @@ var DeviceTools = class {
863
903
  async ({ action, output_path }) => {
864
904
  const p = platform();
865
905
  if (action === "start") {
866
- if (this.screenRecordPid) {
906
+ if (screenRecordPid) {
867
907
  return { content: [{ type: "text", text: "\uC774\uBBF8 \uB179\uD654 \uC911\uC785\uB2C8\uB2E4." }] };
868
908
  }
869
909
  const tmpPath = output_path ?? `/tmp/junis_record_${Date.now()}.mp4`;
@@ -871,17 +911,17 @@ var DeviceTools = class {
871
911
  const cmd = p === "mac" ? ["screencapture", ["-v", tmpPath]] : ["ffmpeg", ["-f", p === "win" ? "gdigrab" : "x11grab", "-i", p === "win" ? "desktop" : ":0.0", tmpPath]];
872
912
  const child = spawn(cmd[0], cmd[1], { detached: true, stdio: "ignore" });
873
913
  child.unref();
874
- this.screenRecordPid = child.pid ?? null;
875
- return { content: [{ type: "text", text: `\uB179\uD654 \uC2DC\uC791\uB428. \uC800\uC7A5 \uACBD\uB85C: ${tmpPath} (PID: ${this.screenRecordPid})` }] };
914
+ screenRecordPid = child.pid ?? null;
915
+ return { content: [{ type: "text", text: `\uB179\uD654 \uC2DC\uC791\uB428. \uC800\uC7A5 \uACBD\uB85C: ${tmpPath} (PID: ${screenRecordPid})` }] };
876
916
  } else {
877
- if (!this.screenRecordPid) {
917
+ if (!screenRecordPid) {
878
918
  return { content: [{ type: "text", text: "\uD604\uC7AC \uB179\uD654 \uC911\uC774 \uC544\uB2D9\uB2C8\uB2E4." }] };
879
919
  }
880
920
  try {
881
- process.kill(this.screenRecordPid, "SIGINT");
921
+ process.kill(screenRecordPid, "SIGINT");
882
922
  } catch {
883
923
  }
884
- this.screenRecordPid = null;
924
+ screenRecordPid = null;
885
925
  return { content: [{ type: "text", text: "\uB179\uD654 \uC911\uC9C0\uB428." }] };
886
926
  }
887
927
  }
@@ -900,12 +940,15 @@ var DeviceTools = class {
900
940
  } catch {
901
941
  }
902
942
  }
903
- const res = await fetch("https://ipapi.co/json/");
943
+ const res = await fetch("http://ip-api.com/json/");
904
944
  const data = await res.json();
945
+ if (data.status !== "success") {
946
+ throw new Error(`IP \uC704\uCE58 \uC870\uD68C \uC2E4\uD328: ${data.message ?? data.status}`);
947
+ }
905
948
  return {
906
949
  content: [{
907
950
  type: "text",
908
- text: `\uC704\uB3C4: ${data.latitude}, \uACBD\uB3C4: ${data.longitude}, \uB3C4\uC2DC: ${data.city}, \uAD6D\uAC00: ${data.country_name} (IP \uAE30\uBC18 \uCD94\uC815)`
951
+ text: `\uC704\uB3C4: ${data.lat}, \uACBD\uB3C4: ${data.lon}, \uB3C4\uC2DC: ${data.city}, \uAD6D\uAC00: ${data.country} (IP \uAE30\uBC18 \uCD94\uC815)`
909
952
  }]
910
953
  };
911
954
  }
@@ -46,6 +46,7 @@ var import_path = __toESM(require("path"));
46
46
  var import_glob = require("glob");
47
47
  var import_zod = require("zod");
48
48
  var execAsync = (0, import_util.promisify)(import_child_process.exec);
49
+ var execFileAsync = (0, import_util.promisify)(import_child_process.execFile);
49
50
  var FilesystemTools = class {
50
51
  register(server) {
51
52
  server.tool(
@@ -130,8 +131,9 @@ ${error.stderr ?? ""}`
130
131
  },
131
132
  async ({ pattern, directory, file_pattern }) => {
132
133
  try {
133
- const { stdout } = await execAsync(
134
- `rg --no-heading -n "${pattern}" ${directory}`,
134
+ const { stdout } = await execFileAsync(
135
+ "rg",
136
+ ["--no-heading", "-n", pattern, directory],
135
137
  { timeout: 1e4 }
136
138
  );
137
139
  return { content: [{ type: "text", text: stdout || "\uACB0\uACFC \uC5C6\uC74C" }] };
@@ -268,6 +270,17 @@ var import_zod2 = require("zod");
268
270
  var BrowserTools = class {
269
271
  browser = null;
270
272
  page = null;
273
+ // 동시 요청 시 race condition 방지용 직렬화 락
274
+ lock = Promise.resolve();
275
+ withLock(fn) {
276
+ let release;
277
+ const next = new Promise((r) => {
278
+ release = r;
279
+ });
280
+ const current = this.lock;
281
+ this.lock = this.lock.then(() => next);
282
+ return current.then(() => fn()).finally(() => release());
283
+ }
271
284
  async init() {
272
285
  try {
273
286
  this.browser = await import_playwright.chromium.launch({ headless: true });
@@ -290,22 +303,22 @@ var BrowserTools = class {
290
303
  "browser_navigate",
291
304
  "URL\uB85C \uC774\uB3D9",
292
305
  { url: import_zod2.z.string().describe("\uC774\uB3D9\uD560 URL") },
293
- async ({ url }) => {
306
+ ({ url }) => this.withLock(async () => {
294
307
  const page = requirePage();
295
308
  await page.goto(url, { waitUntil: "domcontentloaded" });
296
309
  return {
297
310
  content: [{ type: "text", text: `\uC774\uB3D9 \uC644\uB8CC: ${page.url()}` }]
298
311
  };
299
- }
312
+ })
300
313
  );
301
314
  server.tool(
302
315
  "browser_click",
303
316
  "\uC694\uC18C \uD074\uB9AD",
304
317
  { selector: import_zod2.z.string().describe("CSS \uC120\uD0DD\uC790") },
305
- async ({ selector }) => {
318
+ ({ selector }) => this.withLock(async () => {
306
319
  await requirePage().click(selector);
307
320
  return { content: [{ type: "text", text: "\uD074\uB9AD \uC644\uB8CC" }] };
308
- }
321
+ })
309
322
  );
310
323
  server.tool(
311
324
  "browser_type",
@@ -315,12 +328,12 @@ var BrowserTools = class {
315
328
  text: import_zod2.z.string().describe("\uC785\uB825\uD560 \uD14D\uC2A4\uD2B8"),
316
329
  clear: import_zod2.z.boolean().optional().default(false).describe("\uAE30\uC874 \uB0B4\uC6A9 \uC0AD\uC81C \uD6C4 \uC785\uB825")
317
330
  },
318
- async ({ selector, text, clear }) => {
331
+ ({ selector, text, clear }) => this.withLock(async () => {
319
332
  const page = requirePage();
320
333
  if (clear) await page.fill(selector, text);
321
334
  else await page.type(selector, text);
322
335
  return { content: [{ type: "text", text: "\uC785\uB825 \uC644\uB8CC" }] };
323
- }
336
+ })
324
337
  );
325
338
  server.tool(
326
339
  "browser_screenshot",
@@ -329,7 +342,7 @@ var BrowserTools = class {
329
342
  path: import_zod2.z.string().optional().describe("\uC800\uC7A5 \uACBD\uB85C (\uC5C6\uC73C\uBA74 base64 \uBC18\uD658)"),
330
343
  full_page: import_zod2.z.boolean().optional().default(false)
331
344
  },
332
- async ({ path: path2, full_page }) => {
345
+ ({ path: path2, full_page }) => this.withLock(async () => {
333
346
  const page = requirePage();
334
347
  const screenshot = await page.screenshot({
335
348
  path: path2 ?? void 0,
@@ -347,13 +360,13 @@ var BrowserTools = class {
347
360
  }
348
361
  ]
349
362
  };
350
- }
363
+ })
351
364
  );
352
365
  server.tool(
353
366
  "browser_snapshot",
354
367
  "\uD398\uC774\uC9C0 \uC811\uADFC\uC131 \uD2B8\uB9AC \uC870\uD68C (\uAD6C\uC870 \uD30C\uC545\uC6A9)",
355
368
  {},
356
- async () => {
369
+ () => this.withLock(async () => {
357
370
  const page = requirePage();
358
371
  const snapshot = await page.locator("body").ariaSnapshot();
359
372
  return {
@@ -361,29 +374,29 @@ var BrowserTools = class {
361
374
  { type: "text", text: snapshot }
362
375
  ]
363
376
  };
364
- }
377
+ })
365
378
  );
366
379
  server.tool(
367
380
  "browser_evaluate",
368
381
  "JavaScript \uC2E4\uD589",
369
382
  { code: import_zod2.z.string().describe("\uC2E4\uD589\uD560 JavaScript \uCF54\uB4DC") },
370
- async ({ code }) => {
383
+ ({ code }) => this.withLock(async () => {
371
384
  const result = await requirePage().evaluate(code);
372
385
  return {
373
386
  content: [
374
387
  { type: "text", text: JSON.stringify(result, null, 2) }
375
388
  ]
376
389
  };
377
- }
390
+ })
378
391
  );
379
392
  server.tool(
380
393
  "browser_pdf",
381
394
  "\uD604\uC7AC \uD398\uC774\uC9C0 PDF \uC800\uC7A5",
382
395
  { path: import_zod2.z.string().describe("\uC800\uC7A5 \uACBD\uB85C (.pdf)") },
383
- async ({ path: path2 }) => {
396
+ ({ path: path2 }) => this.withLock(async () => {
384
397
  await requirePage().pdf({ path: path2 });
385
398
  return { content: [{ type: "text", text: `PDF \uC800\uC7A5 \uC644\uB8CC: ${path2}` }] };
386
- }
399
+ })
387
400
  );
388
401
  }
389
402
  };
@@ -396,7 +409,11 @@ var import_util2 = require("util");
396
409
  var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
397
410
  async function readNotebook(filePath) {
398
411
  const raw = await import_promises2.default.readFile(filePath, "utf-8");
399
- return JSON.parse(raw);
412
+ try {
413
+ return JSON.parse(raw);
414
+ } catch {
415
+ throw new Error(`\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 .ipynb \uD30C\uC77C: ${filePath}`);
416
+ }
400
417
  }
401
418
  async function writeNotebook(filePath, nb) {
402
419
  await import_promises2.default.writeFile(filePath, JSON.stringify(nb, null, 1), "utf-8");
@@ -495,8 +512,9 @@ var NotebookTools = class {
495
512
  nb.cells.push(newCell);
496
513
  actualIndex = nb.cells.length - 1;
497
514
  } else {
498
- nb.cells.splice(position, 0, newCell);
499
- actualIndex = position;
515
+ const clamped = Math.max(0, Math.min(position, nb.cells.length));
516
+ nb.cells.splice(clamped, 0, newCell);
517
+ actualIndex = clamped;
500
518
  }
501
519
  await writeNotebook(filePath, nb);
502
520
  return { content: [{ type: "text", text: `\uC140 \uCD94\uAC00 \uC644\uB8CC (index: ${actualIndex})` }] };
@@ -527,13 +545,13 @@ var import_child_process3 = require("child_process");
527
545
  var import_util3 = require("util");
528
546
  var import_zod4 = require("zod");
529
547
  var execAsync3 = (0, import_util3.promisify)(import_child_process3.exec);
548
+ var screenRecordPid = null;
530
549
  function platform() {
531
550
  if (process.platform === "darwin") return "mac";
532
551
  if (process.platform === "win32") return "win";
533
552
  return "linux";
534
553
  }
535
554
  var DeviceTools = class {
536
- screenRecordPid = null;
537
555
  register(server) {
538
556
  server.tool(
539
557
  "screen_capture",
@@ -543,15 +561,24 @@ var DeviceTools = class {
543
561
  },
544
562
  async ({ output_path }) => {
545
563
  const p = platform();
564
+ const isTmp = !output_path;
546
565
  const tmpPath = output_path ?? `/tmp/junis_screen_${Date.now()}.png`;
547
566
  const cmd = {
548
567
  mac: `screencapture -x "${tmpPath}"`,
549
568
  win: `nircmd.exe savescreenshot "${tmpPath}"`,
550
569
  linux: `scrot "${tmpPath}"`
551
570
  }[p];
552
- await execAsync3(cmd);
553
- const { readFileSync } = await import("fs");
571
+ try {
572
+ await execAsync3(cmd);
573
+ } catch (err) {
574
+ throw new Error(`\uD654\uBA74 \uCEA1\uCC98 \uC2E4\uD328: ${err.message}`);
575
+ }
576
+ const { readFileSync, unlinkSync } = await import("fs");
554
577
  const data = readFileSync(tmpPath).toString("base64");
578
+ if (isTmp) try {
579
+ unlinkSync(tmpPath);
580
+ } catch {
581
+ }
555
582
  return {
556
583
  content: [{ type: "image", data, mimeType: "image/png" }]
557
584
  };
@@ -565,15 +592,24 @@ var DeviceTools = class {
565
592
  },
566
593
  async ({ output_path }) => {
567
594
  const p = platform();
595
+ const isTmp = !output_path;
568
596
  const tmpPath = output_path ?? `/tmp/junis_cam_${Date.now()}.jpg`;
569
597
  const cmd = {
570
598
  mac: `imagesnap "${tmpPath}"`,
571
599
  win: `ffmpeg -f dshow -i video="Default" -frames:v 1 "${tmpPath}"`,
572
600
  linux: `fswebcam -r 1280x720 "${tmpPath}"`
573
601
  }[p];
574
- await execAsync3(cmd);
575
- const { readFileSync } = await import("fs");
602
+ try {
603
+ await execAsync3(cmd);
604
+ } catch (err) {
605
+ throw new Error(`\uCE74\uBA54\uB77C \uCD2C\uC601 \uC2E4\uD328: ${err.message}`);
606
+ }
607
+ const { readFileSync, unlinkSync } = await import("fs");
576
608
  const data = readFileSync(tmpPath).toString("base64");
609
+ if (isTmp) try {
610
+ unlinkSync(tmpPath);
611
+ } catch {
612
+ }
577
613
  return {
578
614
  content: [{ type: "image", data, mimeType: "image/jpeg" }]
579
615
  };
@@ -633,7 +669,7 @@ var DeviceTools = class {
633
669
  async ({ action, output_path }) => {
634
670
  const p = platform();
635
671
  if (action === "start") {
636
- if (this.screenRecordPid) {
672
+ if (screenRecordPid) {
637
673
  return { content: [{ type: "text", text: "\uC774\uBBF8 \uB179\uD654 \uC911\uC785\uB2C8\uB2E4." }] };
638
674
  }
639
675
  const tmpPath = output_path ?? `/tmp/junis_record_${Date.now()}.mp4`;
@@ -641,17 +677,17 @@ var DeviceTools = class {
641
677
  const cmd = p === "mac" ? ["screencapture", ["-v", tmpPath]] : ["ffmpeg", ["-f", p === "win" ? "gdigrab" : "x11grab", "-i", p === "win" ? "desktop" : ":0.0", tmpPath]];
642
678
  const child = spawn(cmd[0], cmd[1], { detached: true, stdio: "ignore" });
643
679
  child.unref();
644
- this.screenRecordPid = child.pid ?? null;
645
- return { content: [{ type: "text", text: `\uB179\uD654 \uC2DC\uC791\uB428. \uC800\uC7A5 \uACBD\uB85C: ${tmpPath} (PID: ${this.screenRecordPid})` }] };
680
+ screenRecordPid = child.pid ?? null;
681
+ return { content: [{ type: "text", text: `\uB179\uD654 \uC2DC\uC791\uB428. \uC800\uC7A5 \uACBD\uB85C: ${tmpPath} (PID: ${screenRecordPid})` }] };
646
682
  } else {
647
- if (!this.screenRecordPid) {
683
+ if (!screenRecordPid) {
648
684
  return { content: [{ type: "text", text: "\uD604\uC7AC \uB179\uD654 \uC911\uC774 \uC544\uB2D9\uB2C8\uB2E4." }] };
649
685
  }
650
686
  try {
651
- process.kill(this.screenRecordPid, "SIGINT");
687
+ process.kill(screenRecordPid, "SIGINT");
652
688
  } catch {
653
689
  }
654
- this.screenRecordPid = null;
690
+ screenRecordPid = null;
655
691
  return { content: [{ type: "text", text: "\uB179\uD654 \uC911\uC9C0\uB428." }] };
656
692
  }
657
693
  }
@@ -670,12 +706,15 @@ var DeviceTools = class {
670
706
  } catch {
671
707
  }
672
708
  }
673
- const res = await fetch("https://ipapi.co/json/");
709
+ const res = await fetch("http://ip-api.com/json/");
674
710
  const data = await res.json();
711
+ if (data.status !== "success") {
712
+ throw new Error(`IP \uC704\uCE58 \uC870\uD68C \uC2E4\uD328: ${data.message ?? data.status}`);
713
+ }
675
714
  return {
676
715
  content: [{
677
716
  type: "text",
678
- text: `\uC704\uB3C4: ${data.latitude}, \uACBD\uB3C4: ${data.longitude}, \uB3C4\uC2DC: ${data.city}, \uAD6D\uAC00: ${data.country_name} (IP \uAE30\uBC18 \uCD94\uC815)`
717
+ text: `\uC704\uB3C4: ${data.lat}, \uACBD\uB3C4: ${data.lon}, \uB3C4\uC2DC: ${data.city}, \uAD6D\uAC00: ${data.country} (IP \uAE30\uBC18 \uCD94\uC815)`
679
718
  }]
680
719
  };
681
720
  }
@@ -35,6 +35,7 @@ var import_path = __toESM(require("path"));
35
35
  var import_glob = require("glob");
36
36
  var import_zod = require("zod");
37
37
  var execAsync = (0, import_util.promisify)(import_child_process.exec);
38
+ var execFileAsync = (0, import_util.promisify)(import_child_process.execFile);
38
39
  var FilesystemTools = class {
39
40
  register(server) {
40
41
  server.tool(
@@ -119,8 +120,9 @@ ${error.stderr ?? ""}`
119
120
  },
120
121
  async ({ pattern, directory, file_pattern }) => {
121
122
  try {
122
- const { stdout } = await execAsync(
123
- `rg --no-heading -n "${pattern}" ${directory}`,
123
+ const { stdout } = await execFileAsync(
124
+ "rg",
125
+ ["--no-heading", "-n", pattern, directory],
124
126
  { timeout: 1e4 }
125
127
  );
126
128
  return { content: [{ type: "text", text: stdout || "\uACB0\uACFC \uC5C6\uC74C" }] };
@@ -257,6 +259,17 @@ var import_zod2 = require("zod");
257
259
  var BrowserTools = class {
258
260
  browser = null;
259
261
  page = null;
262
+ // 동시 요청 시 race condition 방지용 직렬화 락
263
+ lock = Promise.resolve();
264
+ withLock(fn) {
265
+ let release;
266
+ const next = new Promise((r) => {
267
+ release = r;
268
+ });
269
+ const current = this.lock;
270
+ this.lock = this.lock.then(() => next);
271
+ return current.then(() => fn()).finally(() => release());
272
+ }
260
273
  async init() {
261
274
  try {
262
275
  this.browser = await import_playwright.chromium.launch({ headless: true });
@@ -279,22 +292,22 @@ var BrowserTools = class {
279
292
  "browser_navigate",
280
293
  "URL\uB85C \uC774\uB3D9",
281
294
  { url: import_zod2.z.string().describe("\uC774\uB3D9\uD560 URL") },
282
- async ({ url }) => {
295
+ ({ url }) => this.withLock(async () => {
283
296
  const page = requirePage();
284
297
  await page.goto(url, { waitUntil: "domcontentloaded" });
285
298
  return {
286
299
  content: [{ type: "text", text: `\uC774\uB3D9 \uC644\uB8CC: ${page.url()}` }]
287
300
  };
288
- }
301
+ })
289
302
  );
290
303
  server.tool(
291
304
  "browser_click",
292
305
  "\uC694\uC18C \uD074\uB9AD",
293
306
  { selector: import_zod2.z.string().describe("CSS \uC120\uD0DD\uC790") },
294
- async ({ selector }) => {
307
+ ({ selector }) => this.withLock(async () => {
295
308
  await requirePage().click(selector);
296
309
  return { content: [{ type: "text", text: "\uD074\uB9AD \uC644\uB8CC" }] };
297
- }
310
+ })
298
311
  );
299
312
  server.tool(
300
313
  "browser_type",
@@ -304,12 +317,12 @@ var BrowserTools = class {
304
317
  text: import_zod2.z.string().describe("\uC785\uB825\uD560 \uD14D\uC2A4\uD2B8"),
305
318
  clear: import_zod2.z.boolean().optional().default(false).describe("\uAE30\uC874 \uB0B4\uC6A9 \uC0AD\uC81C \uD6C4 \uC785\uB825")
306
319
  },
307
- async ({ selector, text, clear }) => {
320
+ ({ selector, text, clear }) => this.withLock(async () => {
308
321
  const page = requirePage();
309
322
  if (clear) await page.fill(selector, text);
310
323
  else await page.type(selector, text);
311
324
  return { content: [{ type: "text", text: "\uC785\uB825 \uC644\uB8CC" }] };
312
- }
325
+ })
313
326
  );
314
327
  server.tool(
315
328
  "browser_screenshot",
@@ -318,7 +331,7 @@ var BrowserTools = class {
318
331
  path: import_zod2.z.string().optional().describe("\uC800\uC7A5 \uACBD\uB85C (\uC5C6\uC73C\uBA74 base64 \uBC18\uD658)"),
319
332
  full_page: import_zod2.z.boolean().optional().default(false)
320
333
  },
321
- async ({ path: path2, full_page }) => {
334
+ ({ path: path2, full_page }) => this.withLock(async () => {
322
335
  const page = requirePage();
323
336
  const screenshot = await page.screenshot({
324
337
  path: path2 ?? void 0,
@@ -336,13 +349,13 @@ var BrowserTools = class {
336
349
  }
337
350
  ]
338
351
  };
339
- }
352
+ })
340
353
  );
341
354
  server.tool(
342
355
  "browser_snapshot",
343
356
  "\uD398\uC774\uC9C0 \uC811\uADFC\uC131 \uD2B8\uB9AC \uC870\uD68C (\uAD6C\uC870 \uD30C\uC545\uC6A9)",
344
357
  {},
345
- async () => {
358
+ () => this.withLock(async () => {
346
359
  const page = requirePage();
347
360
  const snapshot = await page.locator("body").ariaSnapshot();
348
361
  return {
@@ -350,29 +363,29 @@ var BrowserTools = class {
350
363
  { type: "text", text: snapshot }
351
364
  ]
352
365
  };
353
- }
366
+ })
354
367
  );
355
368
  server.tool(
356
369
  "browser_evaluate",
357
370
  "JavaScript \uC2E4\uD589",
358
371
  { code: import_zod2.z.string().describe("\uC2E4\uD589\uD560 JavaScript \uCF54\uB4DC") },
359
- async ({ code }) => {
372
+ ({ code }) => this.withLock(async () => {
360
373
  const result = await requirePage().evaluate(code);
361
374
  return {
362
375
  content: [
363
376
  { type: "text", text: JSON.stringify(result, null, 2) }
364
377
  ]
365
378
  };
366
- }
379
+ })
367
380
  );
368
381
  server.tool(
369
382
  "browser_pdf",
370
383
  "\uD604\uC7AC \uD398\uC774\uC9C0 PDF \uC800\uC7A5",
371
384
  { path: import_zod2.z.string().describe("\uC800\uC7A5 \uACBD\uB85C (.pdf)") },
372
- async ({ path: path2 }) => {
385
+ ({ path: path2 }) => this.withLock(async () => {
373
386
  await requirePage().pdf({ path: path2 });
374
387
  return { content: [{ type: "text", text: `PDF \uC800\uC7A5 \uC644\uB8CC: ${path2}` }] };
375
- }
388
+ })
376
389
  );
377
390
  }
378
391
  };
@@ -385,7 +398,11 @@ var import_util2 = require("util");
385
398
  var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
386
399
  async function readNotebook(filePath) {
387
400
  const raw = await import_promises2.default.readFile(filePath, "utf-8");
388
- return JSON.parse(raw);
401
+ try {
402
+ return JSON.parse(raw);
403
+ } catch {
404
+ throw new Error(`\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 .ipynb \uD30C\uC77C: ${filePath}`);
405
+ }
389
406
  }
390
407
  async function writeNotebook(filePath, nb) {
391
408
  await import_promises2.default.writeFile(filePath, JSON.stringify(nb, null, 1), "utf-8");
@@ -484,8 +501,9 @@ var NotebookTools = class {
484
501
  nb.cells.push(newCell);
485
502
  actualIndex = nb.cells.length - 1;
486
503
  } else {
487
- nb.cells.splice(position, 0, newCell);
488
- actualIndex = position;
504
+ const clamped = Math.max(0, Math.min(position, nb.cells.length));
505
+ nb.cells.splice(clamped, 0, newCell);
506
+ actualIndex = clamped;
489
507
  }
490
508
  await writeNotebook(filePath, nb);
491
509
  return { content: [{ type: "text", text: `\uC140 \uCD94\uAC00 \uC644\uB8CC (index: ${actualIndex})` }] };
@@ -516,13 +534,13 @@ var import_child_process3 = require("child_process");
516
534
  var import_util3 = require("util");
517
535
  var import_zod4 = require("zod");
518
536
  var execAsync3 = (0, import_util3.promisify)(import_child_process3.exec);
537
+ var screenRecordPid = null;
519
538
  function platform() {
520
539
  if (process.platform === "darwin") return "mac";
521
540
  if (process.platform === "win32") return "win";
522
541
  return "linux";
523
542
  }
524
543
  var DeviceTools = class {
525
- screenRecordPid = null;
526
544
  register(server) {
527
545
  server.tool(
528
546
  "screen_capture",
@@ -532,15 +550,24 @@ var DeviceTools = class {
532
550
  },
533
551
  async ({ output_path }) => {
534
552
  const p = platform();
553
+ const isTmp = !output_path;
535
554
  const tmpPath = output_path ?? `/tmp/junis_screen_${Date.now()}.png`;
536
555
  const cmd = {
537
556
  mac: `screencapture -x "${tmpPath}"`,
538
557
  win: `nircmd.exe savescreenshot "${tmpPath}"`,
539
558
  linux: `scrot "${tmpPath}"`
540
559
  }[p];
541
- await execAsync3(cmd);
542
- const { readFileSync } = await import("fs");
560
+ try {
561
+ await execAsync3(cmd);
562
+ } catch (err) {
563
+ throw new Error(`\uD654\uBA74 \uCEA1\uCC98 \uC2E4\uD328: ${err.message}`);
564
+ }
565
+ const { readFileSync, unlinkSync } = await import("fs");
543
566
  const data = readFileSync(tmpPath).toString("base64");
567
+ if (isTmp) try {
568
+ unlinkSync(tmpPath);
569
+ } catch {
570
+ }
544
571
  return {
545
572
  content: [{ type: "image", data, mimeType: "image/png" }]
546
573
  };
@@ -554,15 +581,24 @@ var DeviceTools = class {
554
581
  },
555
582
  async ({ output_path }) => {
556
583
  const p = platform();
584
+ const isTmp = !output_path;
557
585
  const tmpPath = output_path ?? `/tmp/junis_cam_${Date.now()}.jpg`;
558
586
  const cmd = {
559
587
  mac: `imagesnap "${tmpPath}"`,
560
588
  win: `ffmpeg -f dshow -i video="Default" -frames:v 1 "${tmpPath}"`,
561
589
  linux: `fswebcam -r 1280x720 "${tmpPath}"`
562
590
  }[p];
563
- await execAsync3(cmd);
564
- const { readFileSync } = await import("fs");
591
+ try {
592
+ await execAsync3(cmd);
593
+ } catch (err) {
594
+ throw new Error(`\uCE74\uBA54\uB77C \uCD2C\uC601 \uC2E4\uD328: ${err.message}`);
595
+ }
596
+ const { readFileSync, unlinkSync } = await import("fs");
565
597
  const data = readFileSync(tmpPath).toString("base64");
598
+ if (isTmp) try {
599
+ unlinkSync(tmpPath);
600
+ } catch {
601
+ }
566
602
  return {
567
603
  content: [{ type: "image", data, mimeType: "image/jpeg" }]
568
604
  };
@@ -622,7 +658,7 @@ var DeviceTools = class {
622
658
  async ({ action, output_path }) => {
623
659
  const p = platform();
624
660
  if (action === "start") {
625
- if (this.screenRecordPid) {
661
+ if (screenRecordPid) {
626
662
  return { content: [{ type: "text", text: "\uC774\uBBF8 \uB179\uD654 \uC911\uC785\uB2C8\uB2E4." }] };
627
663
  }
628
664
  const tmpPath = output_path ?? `/tmp/junis_record_${Date.now()}.mp4`;
@@ -630,17 +666,17 @@ var DeviceTools = class {
630
666
  const cmd = p === "mac" ? ["screencapture", ["-v", tmpPath]] : ["ffmpeg", ["-f", p === "win" ? "gdigrab" : "x11grab", "-i", p === "win" ? "desktop" : ":0.0", tmpPath]];
631
667
  const child = spawn(cmd[0], cmd[1], { detached: true, stdio: "ignore" });
632
668
  child.unref();
633
- this.screenRecordPid = child.pid ?? null;
634
- return { content: [{ type: "text", text: `\uB179\uD654 \uC2DC\uC791\uB428. \uC800\uC7A5 \uACBD\uB85C: ${tmpPath} (PID: ${this.screenRecordPid})` }] };
669
+ screenRecordPid = child.pid ?? null;
670
+ return { content: [{ type: "text", text: `\uB179\uD654 \uC2DC\uC791\uB428. \uC800\uC7A5 \uACBD\uB85C: ${tmpPath} (PID: ${screenRecordPid})` }] };
635
671
  } else {
636
- if (!this.screenRecordPid) {
672
+ if (!screenRecordPid) {
637
673
  return { content: [{ type: "text", text: "\uD604\uC7AC \uB179\uD654 \uC911\uC774 \uC544\uB2D9\uB2C8\uB2E4." }] };
638
674
  }
639
675
  try {
640
- process.kill(this.screenRecordPid, "SIGINT");
676
+ process.kill(screenRecordPid, "SIGINT");
641
677
  } catch {
642
678
  }
643
- this.screenRecordPid = null;
679
+ screenRecordPid = null;
644
680
  return { content: [{ type: "text", text: "\uB179\uD654 \uC911\uC9C0\uB428." }] };
645
681
  }
646
682
  }
@@ -659,12 +695,15 @@ var DeviceTools = class {
659
695
  } catch {
660
696
  }
661
697
  }
662
- const res = await fetch("https://ipapi.co/json/");
698
+ const res = await fetch("http://ip-api.com/json/");
663
699
  const data = await res.json();
700
+ if (data.status !== "success") {
701
+ throw new Error(`IP \uC704\uCE58 \uC870\uD68C \uC2E4\uD328: ${data.message ?? data.status}`);
702
+ }
664
703
  return {
665
704
  content: [{
666
705
  type: "text",
667
- text: `\uC704\uB3C4: ${data.latitude}, \uACBD\uB3C4: ${data.longitude}, \uB3C4\uC2DC: ${data.city}, \uAD6D\uAC00: ${data.country_name} (IP \uAE30\uBC18 \uCD94\uC815)`
706
+ text: `\uC704\uB3C4: ${data.lat}, \uACBD\uB3C4: ${data.lon}, \uB3C4\uC2DC: ${data.city}, \uAD6D\uAC00: ${data.country} (IP \uAE30\uBC18 \uCD94\uC815)`
668
707
  }]
669
708
  };
670
709
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "junis",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "One-line device control for AI agents",
5
5
  "bin": {
6
6
  "junis": "dist/cli/index.js"