codeam-cli 1.3.7 → 1.3.9

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.
Files changed (2) hide show
  1. package/dist/index.js +253 -114
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -24,9 +24,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/commands/start.ts
27
- var fs3 = __toESM(require("fs"));
27
+ var fs4 = __toESM(require("fs"));
28
28
  var os4 = __toESM(require("os"));
29
- var path3 = __toESM(require("path"));
29
+ var path4 = __toESM(require("path"));
30
30
  var import_crypto = require("crypto");
31
31
  var import_picocolors2 = __toESM(require("picocolors"));
32
32
 
@@ -114,7 +114,7 @@ var import_picocolors = __toESM(require("picocolors"));
114
114
  // package.json
115
115
  var package_default = {
116
116
  name: "codeam-cli",
117
- version: "1.3.7",
117
+ version: "1.3.9",
118
118
  description: "Remote control Claude Code from your mobile device",
119
119
  main: "dist/index.js",
120
120
  bin: {
@@ -511,11 +511,27 @@ var CommandRelayService = class {
511
511
  }
512
512
  };
513
513
 
514
- // src/services/claude.service.ts
515
- var import_child_process = require("child_process");
514
+ // src/services/pty/types.ts
516
515
  var fs2 = __toESM(require("fs"));
517
- var os3 = __toESM(require("os"));
518
516
  var path2 = __toESM(require("path"));
517
+ function findInPath(name) {
518
+ const dirs = (process.env.PATH ?? "").split(path2.delimiter);
519
+ for (const dir of dirs) {
520
+ const full = `${dir}/${name}`;
521
+ try {
522
+ fs2.accessSync(full, fs2.constants.X_OK);
523
+ return full;
524
+ } catch {
525
+ }
526
+ }
527
+ return null;
528
+ }
529
+
530
+ // src/services/pty/unix.strategy.ts
531
+ var import_child_process = require("child_process");
532
+ var fs3 = __toESM(require("fs"));
533
+ var os3 = __toESM(require("os"));
534
+ var path3 = __toESM(require("path"));
519
535
  var PYTHON_PTY_HELPER = `import os,pty,sys,select,signal,struct,fcntl,termios,errno
520
536
  m,s=pty.openpty()
521
537
  try:
@@ -571,53 +587,30 @@ try:
571
587
  sys.exit((st>>8)&0xFF)
572
588
  except Exception:sys.exit(0)
573
589
  `;
574
- var ClaudeService = class {
590
+ var UnixPtyStrategy = class {
575
591
  constructor(opts) {
576
592
  this.opts = opts;
577
593
  }
578
594
  opts;
579
595
  proc = null;
580
596
  helperPath = null;
581
- spawn() {
582
- if (this.proc) {
583
- this.cleanup();
584
- this.proc.kill();
585
- this.proc = null;
586
- }
587
- if (!findInPath("claude") && !findInPath("claude-code")) {
588
- console.error(
589
- "\n \u2717 claude not found in PATH.\n Install it with: npm install -g @anthropic-ai/claude-code\n"
590
- );
591
- process.exit(1);
592
- }
593
- const claudeCmd = findInPath("claude") ? "claude" : "claude-code";
594
- if (process.platform === "win32") {
595
- this.spawnDirect(claudeCmd);
596
- } else {
597
- this.spawnWithPty(claudeCmd);
598
- }
599
- }
600
- /**
601
- * macOS / Linux: use a Python PTY helper to give Claude a real TTY.
602
- * Falls back to direct spawn if python3 is not available.
603
- */
604
- spawnWithPty(claudeCmd) {
597
+ spawn(cmd, cwd) {
605
598
  const python = findInPath("python3") ?? findInPath("python");
606
599
  if (!python) {
607
600
  console.error(
608
601
  " \xB7 python3 not found; mobile command injection may be limited.\n"
609
602
  );
610
- this.spawnDirect(claudeCmd);
603
+ this.spawnDirect(cmd, cwd);
611
604
  return;
612
605
  }
613
606
  const shell = process.env.SHELL || "/bin/sh";
614
607
  const cols = process.stdout.columns || 220;
615
608
  const rows = process.stdout.rows || 50;
616
- this.helperPath = path2.join(os3.tmpdir(), "codeam-pty-helper.py");
617
- fs2.writeFileSync(this.helperPath, PYTHON_PTY_HELPER, { mode: 420 });
618
- this.proc = (0, import_child_process.spawn)(python, [this.helperPath, shell, "-c", `exec ${claudeCmd}`], {
609
+ this.helperPath = path3.join(os3.tmpdir(), "codeam-pty-helper.py");
610
+ fs3.writeFileSync(this.helperPath, PYTHON_PTY_HELPER, { mode: 420 });
611
+ this.proc = (0, import_child_process.spawn)(python, [this.helperPath, shell, "-c", `exec ${cmd}`], {
619
612
  stdio: ["pipe", "pipe", "inherit"],
620
- cwd: this.opts.cwd,
613
+ cwd,
621
614
  env: {
622
615
  ...process.env,
623
616
  TERM: "xterm-256color",
@@ -636,7 +629,7 @@ var ClaudeService = class {
636
629
  });
637
630
  this.proc.stdout?.on("data", (chunk) => {
638
631
  process.stdout.write(chunk);
639
- this.opts.onData?.(chunk.toString("utf8"));
632
+ this.opts.onData(chunk.toString("utf8"));
640
633
  });
641
634
  if (process.stdin.isTTY) process.stdin.setRawMode(true);
642
635
  process.stdin.resume();
@@ -644,18 +637,18 @@ var ClaudeService = class {
644
637
  process.on("SIGWINCH", this.handleResize);
645
638
  this.proc.on("exit", (code) => {
646
639
  this.removeTempFile();
647
- this.cleanup();
640
+ this.dispose();
648
641
  this.opts.onExit(code ?? 0);
649
642
  });
650
643
  }
651
644
  /**
652
- * Windows (or Python-unavailable) fallback: direct spawn without PTY.
653
- * Mobile command injection is limited on Windows.
645
+ * Python-unavailable fallback: direct spawn without PTY.
646
+ * Mobile command injection is limited (no real TTY for Claude).
654
647
  */
655
- spawnDirect(claudeCmd) {
656
- this.proc = (0, import_child_process.spawn)(claudeCmd, [], {
648
+ spawnDirect(cmd, cwd) {
649
+ this.proc = (0, import_child_process.spawn)(cmd, [], {
657
650
  stdio: ["pipe", "inherit", "inherit"],
658
- cwd: this.opts.cwd,
651
+ cwd,
659
652
  env: process.env,
660
653
  shell: true
661
654
  });
@@ -674,54 +667,27 @@ var ClaudeService = class {
674
667
  process.stdin.on("data", this.stdinHandler);
675
668
  process.on("SIGWINCH", this.handleResize);
676
669
  this.proc.on("exit", (code) => {
677
- this.cleanup();
670
+ this.dispose();
678
671
  this.opts.onExit(code ?? 0);
679
672
  });
680
673
  }
681
- /** Send a command to Claude's stdin (remote control from mobile). */
682
- sendCommand(text) {
683
- this.proc?.stdin?.write(text + "\r");
684
- }
685
- /**
686
- * Navigate a React Ink selector to the given 0-based index and confirm.
687
- *
688
- * Why not sendCommand(arrows + Enter) in one write()?
689
- * All bytes arrive as one chunk → readline fires all keypress events in the
690
- * same synchronous run → React Ink batches the state updates → each arrow
691
- * sees selectedIndex=0 → final state is still 0 or 1 → wrong option selected.
692
- *
693
- * Fix: send each down-arrow in a separate write(), ARROW_MS apart, so React
694
- * has time to process and re-render between each keystroke. Enter is sent
695
- * ENTER_MS after the last arrow.
696
- */
697
- selectOption(index) {
698
- if (index <= 0) {
699
- this.proc?.stdin?.write("\r");
700
- return;
701
- }
702
- const ARROW_MS = 80;
703
- const ENTER_MS = 200;
704
- for (let i = 0; i < index; i++) {
705
- setTimeout(() => {
706
- this.proc?.stdin?.write("\x1B[B");
707
- }, i * ARROW_MS);
708
- }
709
- setTimeout(() => {
710
- this.proc?.stdin?.write("\r");
711
- }, index * ARROW_MS + ENTER_MS);
712
- }
713
- /** Send Escape key to Claude (cancels interactive prompts). */
714
- sendEscape() {
715
- this.proc?.stdin?.write("\x1B");
716
- }
717
- /** Send Ctrl+C to Claude. */
718
- interrupt() {
719
- this.proc?.stdin?.write("");
674
+ write(data) {
675
+ this.proc?.stdin?.write(data);
720
676
  }
721
677
  kill() {
722
678
  this.proc?.kill();
723
679
  this.removeTempFile();
724
- this.cleanup();
680
+ this.dispose();
681
+ }
682
+ dispose() {
683
+ process.removeListener("SIGWINCH", this.handleResize);
684
+ process.stdin.removeListener("data", this.stdinHandler);
685
+ if (process.stdin.isTTY) {
686
+ try {
687
+ process.stdin.setRawMode(false);
688
+ } catch {
689
+ }
690
+ }
725
691
  }
726
692
  stdinHandler = (chunk) => {
727
693
  this.proc?.stdin?.write(chunk);
@@ -734,38 +700,156 @@ var ClaudeService = class {
734
700
  }
735
701
  }
736
702
  };
737
- cleanup() {
738
- process.removeListener("SIGWINCH", this.handleResize);
739
- process.stdin.removeListener("data", this.stdinHandler);
740
- if (process.stdin.isTTY) {
703
+ removeTempFile() {
704
+ if (this.helperPath) {
741
705
  try {
742
- process.stdin.setRawMode(false);
706
+ fs3.unlinkSync(this.helperPath);
743
707
  } catch {
744
708
  }
709
+ this.helperPath = null;
745
710
  }
746
711
  }
747
- removeTempFile() {
748
- if (this.helperPath) {
712
+ };
713
+
714
+ // src/services/pty/windows.strategy.ts
715
+ var import_child_process2 = require("child_process");
716
+ var WindowsPtyStrategy = class {
717
+ constructor(opts) {
718
+ this.opts = opts;
719
+ }
720
+ opts;
721
+ proc = null;
722
+ spawn(cmd, cwd) {
723
+ this.proc = (0, import_child_process2.spawn)(cmd, [], {
724
+ stdio: ["pipe", "pipe", "inherit"],
725
+ cwd,
726
+ env: {
727
+ ...process.env,
728
+ TERM: "xterm-256color",
729
+ COLUMNS: "220",
730
+ LINES: "50"
731
+ },
732
+ shell: true
733
+ });
734
+ this.proc.on("error", (err) => {
735
+ console.error(
736
+ `
737
+ \u2717 Failed to launch Claude Code: ${err.message}
738
+ Make sure claude is correctly installed: npm install -g @anthropic-ai/claude-code
739
+ `
740
+ );
741
+ process.exit(1);
742
+ });
743
+ this.proc.stdout?.on("data", (chunk) => {
744
+ process.stdout.write(chunk);
745
+ this.opts.onData(chunk.toString("utf8"));
746
+ });
747
+ this.proc.stdin?.write("");
748
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
749
+ process.stdin.resume();
750
+ process.stdin.on("data", this.stdinHandler);
751
+ this.proc.on("exit", (code) => {
752
+ this.dispose();
753
+ this.opts.onExit(code ?? 0);
754
+ });
755
+ }
756
+ write(data) {
757
+ this.proc?.stdin?.write(data);
758
+ }
759
+ kill() {
760
+ this.proc?.kill();
761
+ this.dispose();
762
+ }
763
+ dispose() {
764
+ process.stdin.removeListener("data", this.stdinHandler);
765
+ if (process.stdin.isTTY) {
749
766
  try {
750
- fs2.unlinkSync(this.helperPath);
767
+ process.stdin.setRawMode(false);
751
768
  } catch {
752
769
  }
753
- this.helperPath = null;
754
770
  }
755
771
  }
772
+ stdinHandler = (chunk) => {
773
+ this.proc?.stdin?.write(chunk);
774
+ };
756
775
  };
757
- function findInPath(name) {
758
- const dirs = (process.env.PATH ?? "").split(path2.delimiter);
759
- for (const dir of dirs) {
760
- const full = `${dir}/${name}`;
761
- try {
762
- fs2.accessSync(full, fs2.constants.X_OK);
763
- return full;
764
- } catch {
776
+
777
+ // src/services/claude.service.ts
778
+ var ClaudeService = class {
779
+ constructor(opts) {
780
+ this.opts = opts;
781
+ const strategyOpts = {
782
+ onData: opts.onData ?? (() => {
783
+ }),
784
+ onExit: opts.onExit
785
+ };
786
+ this.strategy = process.platform === "win32" ? new WindowsPtyStrategy(strategyOpts) : new UnixPtyStrategy(strategyOpts);
787
+ }
788
+ opts;
789
+ strategy;
790
+ spawn() {
791
+ if (!findInPath("claude") && !findInPath("claude-code")) {
792
+ console.error(
793
+ "\n \u2717 claude not found in PATH.\n Install it with: npm install -g @anthropic-ai/claude-code\n"
794
+ );
795
+ process.exit(1);
765
796
  }
797
+ const claudeCmd = findInPath("claude") ? "claude" : "claude-code";
798
+ this.strategy.spawn(claudeCmd, this.opts.cwd);
766
799
  }
767
- return null;
768
- }
800
+ /** Send a command to Claude's stdin (remote control from mobile). */
801
+ sendCommand(text) {
802
+ this.strategy.write(text + "\r");
803
+ }
804
+ /**
805
+ * Navigate a React Ink selector to the given 0-based target index and confirm.
806
+ *
807
+ * `fromIndex` is the current highlighted position (defaults to 0 for
808
+ * numbered selectors which always start at the first option). For list-style
809
+ * selectors (e.g. /mcp), the CLI sends `currentIndex` in the select_prompt
810
+ * chunk so the client can pass it back here as `fromIndex`, enabling both
811
+ * up-arrow and down-arrow navigation without always rewinding to position 0.
812
+ *
813
+ * Why not sendCommand(arrows + Enter) in one write()?
814
+ * All bytes arrive as one chunk → readline fires all keypress events in the
815
+ * same synchronous run → React Ink batches the state updates → each arrow
816
+ * sees selectedIndex=0 → final state is still 0 or 1 → wrong option selected.
817
+ *
818
+ * Fix: send each arrow in a separate write(), ARROW_MS apart, so React has
819
+ * time to process and re-render between each keystroke. Enter is sent
820
+ * ENTER_MS after the last arrow.
821
+ */
822
+ selectOption(targetIndex, fromIndex = 0) {
823
+ const delta = targetIndex - fromIndex;
824
+ const steps = Math.abs(delta);
825
+ const arrow = delta >= 0 ? "\x1B[B" : "\x1B[A";
826
+ const ARROW_MS = 80;
827
+ const ENTER_MS = 200;
828
+ if (steps === 0) {
829
+ this.strategy.write("\r");
830
+ return;
831
+ }
832
+ for (let i = 0; i < steps; i++) {
833
+ setTimeout(() => {
834
+ this.strategy.write(arrow);
835
+ }, i * ARROW_MS);
836
+ }
837
+ setTimeout(() => {
838
+ this.strategy.write("\r");
839
+ }, steps * ARROW_MS + ENTER_MS);
840
+ }
841
+ /** Send Escape key to Claude (cancels interactive prompts). */
842
+ sendEscape() {
843
+ this.strategy.write("\x1B");
844
+ }
845
+ /** Send Ctrl+C to Claude. */
846
+ interrupt() {
847
+ this.strategy.write("");
848
+ }
849
+ kill() {
850
+ this.strategy.kill();
851
+ }
852
+ };
769
853
 
770
854
  // src/services/output.service.ts
771
855
  var https2 = __toESM(require("https"));
@@ -920,7 +1004,60 @@ function detectSelector(lines) {
920
1004
  return {
921
1005
  question,
922
1006
  options: keys.map((k) => optionLabels.get(k)),
923
- optionDescriptions: keys.map((k) => (optionDescs.get(k) ?? []).join(" ").trim())
1007
+ optionDescriptions: keys.map((k) => (optionDescs.get(k) ?? []).join(" ").trim()),
1008
+ currentIndex: 0
1009
+ };
1010
+ }
1011
+ function detectListSelector(lines) {
1012
+ if (!lines.some((l) => /[↑↓].*navigate/i.test(l.trim()))) return null;
1013
+ if (lines.some((l) => /^❯\s*\d+\./.test(l.trim()))) return null;
1014
+ if (!lines.some((l) => /^\s+❯\s+\S/.test(l))) return null;
1015
+ const isSelected = (line) => /^\s+❯\s+\S/.test(line);
1016
+ const isUnselected = (line) => /^ \S/.test(line);
1017
+ const isItem = (line) => isSelected(line) || isUnselected(line);
1018
+ let optionStartIdx = -1;
1019
+ for (let i = 0; i < lines.length; i++) {
1020
+ if (isItem(lines[i])) {
1021
+ optionStartIdx = i;
1022
+ break;
1023
+ }
1024
+ }
1025
+ if (optionStartIdx === -1) return null;
1026
+ const questionParts = [];
1027
+ for (let i = 0; i < optionStartIdx; i++) {
1028
+ const t = lines[i].trim();
1029
+ if (!t) continue;
1030
+ if (/^[─━—═\-]{3,}$/.test(t)) continue;
1031
+ if (/[┌└│┐┘├┤┬┴┼]/.test(t)) {
1032
+ const inner = t.replace(/[│┌└┐┘├┤┬┴┼─]/g, "").trim();
1033
+ if (inner) questionParts.push(inner);
1034
+ continue;
1035
+ }
1036
+ if (/^[>❯]\s/.test(t)) continue;
1037
+ if (/[↑↓].*navigate/i.test(t)) continue;
1038
+ questionParts.push(t);
1039
+ }
1040
+ const question = questionParts.join(" ").trim();
1041
+ const options = [];
1042
+ let currentIndex = 0;
1043
+ for (const line of lines.slice(optionStartIdx)) {
1044
+ const t = line.trim();
1045
+ if (!t) continue;
1046
+ if (/[↑↓].*navigate/i.test(t)) break;
1047
+ if (/^[─━—═\-]{3,}$/.test(t)) continue;
1048
+ if (isSelected(line)) {
1049
+ currentIndex = options.length;
1050
+ options.push(t.replace(/^❯\s+/, "").trim());
1051
+ } else if (isUnselected(line)) {
1052
+ options.push(t);
1053
+ }
1054
+ }
1055
+ if (options.length < 2) return null;
1056
+ return {
1057
+ question,
1058
+ options,
1059
+ optionDescriptions: options.map(() => ""),
1060
+ currentIndex
924
1061
  };
925
1062
  }
926
1063
  function filterChrome(lines) {
@@ -1002,13 +1139,13 @@ var OutputService = class _OutputService {
1002
1139
  return;
1003
1140
  }
1004
1141
  const lines = renderToLines(this.rawBuffer);
1005
- const selector = detectSelector(lines);
1142
+ const selector = detectSelector(lines) ?? detectListSelector(lines);
1006
1143
  if (selector) {
1007
1144
  const idleMs2 = this.lastPushTime > 0 ? now - this.lastPushTime : elapsed;
1008
1145
  if (idleMs2 >= _OutputService.SELECTOR_IDLE_MS) {
1009
1146
  this.stopPoll();
1010
1147
  this.active = false;
1011
- this.postChunk({ type: "select_prompt", content: selector.question, options: selector.options, optionDescriptions: selector.optionDescriptions, done: true }).catch(() => {
1148
+ this.postChunk({ type: "select_prompt", content: selector.question, options: selector.options, optionDescriptions: selector.optionDescriptions, currentIndex: selector.currentIndex, done: true }).catch(() => {
1012
1149
  });
1013
1150
  }
1014
1151
  return;
@@ -1031,11 +1168,11 @@ var OutputService = class _OutputService {
1031
1168
  }
1032
1169
  finalize() {
1033
1170
  const lines = renderToLines(this.rawBuffer);
1034
- const selector = detectSelector(lines);
1171
+ const selector = detectSelector(lines) ?? detectListSelector(lines);
1035
1172
  this.stopPoll();
1036
1173
  this.active = false;
1037
1174
  if (selector) {
1038
- this.postChunk({ type: "select_prompt", content: selector.question, options: selector.options, optionDescriptions: selector.optionDescriptions, done: true }).catch(() => {
1175
+ this.postChunk({ type: "select_prompt", content: selector.question, options: selector.options, optionDescriptions: selector.optionDescriptions, currentIndex: selector.currentIndex, done: true }).catch(() => {
1039
1176
  });
1040
1177
  } else {
1041
1178
  const content = filterChrome(lines).join("\n").replace(/\n{3,}/g, "\n\n").trim();
@@ -1103,8 +1240,8 @@ var OutputService = class _OutputService {
1103
1240
  function saveFilesTemp(files) {
1104
1241
  return files.filter(({ base64 }) => base64 && base64.length > 0).map(({ filename, base64 }) => {
1105
1242
  const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
1106
- const tmpPath = path3.join(os4.tmpdir(), `codeam-${(0, import_crypto.randomUUID)()}-${safeName}`);
1107
- fs3.writeFileSync(tmpPath, Buffer.from(base64, "base64"));
1243
+ const tmpPath = path4.join(os4.tmpdir(), `codeam-${(0, import_crypto.randomUUID)()}-${safeName}`);
1244
+ fs4.writeFileSync(tmpPath, Buffer.from(base64, "base64"));
1108
1245
  return tmpPath;
1109
1246
  });
1110
1247
  }
@@ -1140,7 +1277,7 @@ async function start() {
1140
1277
  setTimeout(() => {
1141
1278
  for (const p2 of paths) {
1142
1279
  try {
1143
- fs3.unlinkSync(p2);
1280
+ fs4.unlinkSync(p2);
1144
1281
  } catch {
1145
1282
  }
1146
1283
  }
@@ -1157,8 +1294,9 @@ async function start() {
1157
1294
  }
1158
1295
  case "select_option": {
1159
1296
  const index = cmd.payload.index ?? 0;
1297
+ const from = cmd.payload.from ?? 0;
1160
1298
  outputSvc.newTurn();
1161
- claude.selectOption(index);
1299
+ claude.selectOption(index, from);
1162
1300
  break;
1163
1301
  }
1164
1302
  case "escape_key":
@@ -1191,7 +1329,7 @@ async function start() {
1191
1329
  setTimeout(() => {
1192
1330
  for (const p2 of paths) {
1193
1331
  try {
1194
- fs3.unlinkSync(p2);
1332
+ fs4.unlinkSync(p2);
1195
1333
  } catch {
1196
1334
  }
1197
1335
  }
@@ -1204,8 +1342,9 @@ async function start() {
1204
1342
  if (input) sendPrompt(input);
1205
1343
  } else if (cmdType === "select_option") {
1206
1344
  const index = inner.index ?? 0;
1345
+ const from = inner.from ?? 0;
1207
1346
  outputSvc.newTurn();
1208
- claude.selectOption(index);
1347
+ claude.selectOption(index, from);
1209
1348
  } else if (cmdType === "escape_key") {
1210
1349
  outputSvc.newTurn();
1211
1350
  claude.sendEscape();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeam-cli",
3
- "version": "1.3.7",
3
+ "version": "1.3.9",
4
4
  "description": "Remote control Claude Code from your mobile device",
5
5
  "main": "dist/index.js",
6
6
  "bin": {