remote-claude 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,7 +23,19 @@ Claude Code 只能在启动它的那个终端窗口里操作。一旦离开电
23
23
 
24
24
  ## 快速开始
25
25
 
26
- ### 1. 克隆并初始化
26
+ ### 1. 安装
27
+
28
+ 以下安装方式2选1, 安装后重启shell生效
29
+
30
+ #### 1.1 npm安装
31
+
32
+ ```bash
33
+ npm install remote-claude
34
+ ```
35
+
36
+ 需要安装uv、tmux等依赖,第一次可能有点慢.
37
+
38
+ #### 1.2 或 源码安装
27
39
 
28
40
  ```bash
29
41
  git clone https://github.com/yyzybb537/remote_claude.git
@@ -39,8 +51,9 @@ cd remote_claude
39
51
  |------|------|
40
52
  | `cla` | 启动 Claude (以当前目录路径为会话名) |
41
53
  | `cl` | 同 `cla`,但跳过权限确认 |
54
+ | `remote-claude` | 管理工具(一般不用)|
42
55
 
43
- ### 3. 从其他终端连接(一般不需要)
56
+ ### 3. 从其他终端连接(比较少用)
44
57
 
45
58
  ```bash
46
59
  remote-claude list
@@ -52,9 +65,9 @@ remote-claude attach <会话名>
52
65
  #### 4.1 配置飞书机器人
53
66
 
54
67
  1. 登录[飞书开放平台](https://open.feishu.cn/),创建企业自建应用
55
- 2. 执行命令`cp .env.example .env`, 获取 **App ID** 和 **App Secret**,填入 `.env` 文件
56
- 3. 用`cla`或`cl`启动一次claude(会附带启动飞书客户端,如果之前已经启动过,需要重启飞书客户端: "remote-claude lark restart"
57
- 4. 企业自建应用页面`添加应用能力`(机器人能力)
68
+ 2. 获取 **App ID** 和 **App Secret**
69
+ 3. 用`cla`或`cl`启动一次claude, 按照交互提示填入**App ID** **App Secret**
70
+ 4. [飞书开放平台]的企业自建应用页面`添加应用能力`(机器人能力)
58
71
  5. 企业自建应用页面配置事件回调(如果第3步没启动成功这里配置不了):
59
72
  - `事件与回调` -> `事件配置` -> `订阅方式`右边的笔图标 -> `选择:使用长连接接收事件` -> `点击保存` -> `下面添加事件: 接收消息 v2.0 (im.message.receive_v1)`
60
73
  - `事件与回调` -> `回调配置` -> `订阅方式`右边的笔图标 -> `选择:使用长连接接收回调` -> `点击保存` -> `下面添加回调: 卡片回传交互 (card.action.trigger)`
@@ -187,7 +200,6 @@ remote-claude attach <会话名>
187
200
 
188
201
  1. 从飞书搜素刚刚创建的飞书机器人(第一次搜比较慢,如果搜不到可能是忘记发布了)
189
202
  2. 飞书中与机器人对话,可用命令:
190
- - `/help` 展示可用命令
191
203
  - `/menu` 展示菜单卡片,后续操作都操作这个卡片上的按钮即可
192
204
 
193
205
  ## 使用指南
@@ -199,7 +211,7 @@ remote-claude attach <会话名>
199
211
  | `cla` | 启动飞书客户端 + 以当前目录路径为会话名启动 Claude |
200
212
  | `cl` | 同 `cla`,但跳过权限确认 |
201
213
 
202
- ### 终端命令
214
+ ### 管理命令 (一般不需要)
203
215
 
204
216
  ```bash
205
217
  remote-claude start <会话名> # 启动新会话
package/bin/remote-claude CHANGED
@@ -13,6 +13,31 @@ if ! command -v uv &>/dev/null; then
13
13
  [ -f "$HOME/.local/bin/uv" ] && export PATH="$HOME/.local/bin:$PATH"
14
14
  fi
15
15
 
16
+ # log 子命令:查看最后启动的 session 日志
17
+ if [ "$1" = "log" ]; then
18
+ LOG_DIR="/tmp/remote-claude"
19
+ if [ -n "$2" ]; then
20
+ # 指定 session 名,/ 和 . 替换为 _(与 _safe_filename 一致)
21
+ SESSION_SAFE=$(echo "$2" | tr '/.' '__')
22
+ LOG_FILE="$LOG_DIR/${SESSION_SAFE}_messages.log"
23
+ else
24
+ # 找最后启动的 session(最新的 session .pid 文件,排除 lark.pid)
25
+ LATEST_PID=$(ls -t "$LOG_DIR"/_*.pid 2>/dev/null | head -1)
26
+ if [ -n "$LATEST_PID" ]; then
27
+ SESSION_SAFE=$(basename "$LATEST_PID" .pid)
28
+ LOG_FILE="$LOG_DIR/${SESSION_SAFE}_messages.log"
29
+ fi
30
+ fi
31
+
32
+ if [ -z "$LOG_FILE" ] || [ ! -f "$LOG_FILE" ]; then
33
+ echo "没有找到日志文件(请先启动一个会话)" >&2
34
+ exit 1
35
+ fi
36
+
37
+ echo "📄 $LOG_FILE"
38
+ exec tail -50f "$LOG_FILE"
39
+ fi
40
+
16
41
  # lark 子命令:检查 .env 配置
17
42
  if [ "$1" = "lark" ]; then
18
43
  source "$INSTALL_DIR/scripts/check-env.sh" "$INSTALL_DIR"
@@ -522,6 +522,22 @@ def _render_block_colored(block_dict: dict) -> Optional[str]:
522
522
  return None
523
523
 
524
524
 
525
+ def _render_plan_block(block_dict: dict) -> Optional[Dict[str, Any]]:
526
+ """将 PlanBlock 渲染为飞书 collapsible_panel element"""
527
+ content = block_dict.get("content", "")
528
+ if not content:
529
+ return None
530
+ title = block_dict.get("title", "计划")
531
+ ansi_content = block_dict.get("ansi_content", "")
532
+ content_md = _ansi_to_lark_md(ansi_content) if ansi_content else _escape_md(content)
533
+ return {
534
+ "tag": "collapsible_panel",
535
+ "expanded": True,
536
+ "header": {"title": {"tag": "plain_text", "content": f"📋 {title}"}},
537
+ "elements": [{"tag": "markdown", "content": content_md}],
538
+ }
539
+
540
+
525
541
  def _determine_header(
526
542
  blocks: List[dict],
527
543
  status_line: Optional[dict],
@@ -609,6 +625,13 @@ def build_stream_card(
609
625
  has_content = False
610
626
 
611
627
  for block_dict in blocks:
628
+ typ = block_dict.get("_type", "")
629
+ if typ == "PlanBlock":
630
+ plan_el = _render_plan_block(block_dict)
631
+ if plan_el:
632
+ has_content = True
633
+ elements.append(plan_el)
634
+ continue
612
635
  rendered = _render_block_colored(block_dict)
613
636
  if rendered:
614
637
  has_content = True
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
package/remote_claude.py CHANGED
@@ -30,7 +30,8 @@ from utils.session import (
30
30
  list_active_sessions, is_session_active, cleanup_session,
31
31
  is_lark_running, get_lark_pid, get_lark_status, get_lark_pid_file,
32
32
  save_lark_status, cleanup_lark,
33
- USER_DATA_DIR, ensure_user_data_dir, get_lark_log_file
33
+ USER_DATA_DIR, ensure_user_data_dir, get_lark_log_file,
34
+ get_env_snapshot_path
34
35
  )
35
36
 
36
37
 
@@ -56,6 +57,15 @@ def cmd_start(args):
56
57
 
57
58
  ensure_socket_dir()
58
59
 
60
+ # 将当前 shell 的完整环境变量保存到快照文件(权限 0600 防止密钥泄露)
61
+ # tmux new-session 继承的是 tmux 服务器的全局环境,而非调用方 shell 的环境,
62
+ # 通过快照文件将完整环境传递给 server.py 的 _start_pty()
63
+ import json
64
+ env_snapshot_path = get_env_snapshot_path(session_name)
65
+ env_fd = os.open(str(env_snapshot_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
66
+ with os.fdopen(env_fd, 'w') as f:
67
+ json.dump(dict(os.environ), f)
68
+
59
69
  # 构建 server 命令
60
70
  server_script = SCRIPT_DIR / "server" / "server.py"
61
71
  claude_args = args.claude_args if args.claude_args else []
@@ -21,6 +21,8 @@ if [ "$ENV_OK" = false ]; then
21
21
  echo "飞书客户端尚未配置,需要填写应用凭证。"
22
22
  echo "(在飞书开发者后台创建应用获取: https://open.feishu.cn/app)"
23
23
  echo ""
24
+ echo -e "\033[33m飞书机器人配置文档参考:https://github.com/yyzybb537/remote_claude\033[0m"
25
+ echo ""
24
26
  read -p "FEISHU_APP_ID: " INPUT_APP_ID
25
27
  read -p "FEISHU_APP_SECRET: " INPUT_APP_SECRET
26
28
 
@@ -36,6 +38,6 @@ if [ "$ENV_OK" = false ]; then
36
38
 
37
39
  echo ""
38
40
  echo "配置已保存到 $ENV_FILE"
39
- echo "(可选配置如 BOT_NAME、白名单等可稍后编辑该文件)"
41
+ echo "(可选配置, "白名单"等可稍后编辑该文件)"
40
42
  echo ""
41
43
  fi
@@ -40,6 +40,65 @@ BOX_CORNER_TOP: Set[str] = {'╭', '┌'}
40
40
  BOX_CORNER_BOTTOM: Set[str] = {'╰', '└'}
41
41
  BOX_VERTICAL: Set[str] = {'│', '┃', '║'}
42
42
 
43
+ # OutputBlock 内嵌框线清理(纯文本版,用于 content 检测)
44
+ _INLINE_BOX_TOP_RE = re.compile(r'^\s*[╭┌][─━═╌]+[╮┐]\s*$')
45
+ _INLINE_BOX_BOTTOM_RE = re.compile(r'^\s*[╰└][─━═╌]+[╯┘]\s*$')
46
+ _INLINE_BOX_LEFT_RE = re.compile(r'^(\s*)[│┃║] ?')
47
+ _INLINE_BOX_RIGHT_RE = re.compile(r'\s*[│┃║]\s*$')
48
+
49
+ # ANSI 版(用于 ansi_content 清理)
50
+ _A = r'(?:\x1b\[[\d;]*m)*'
51
+ _ANSI_BOX_LEFT_RE = re.compile(rf'^({_A}\s*){_A}[│┃║]{_A} ?')
52
+ _ANSI_BOX_RIGHT_RE = re.compile(rf'\s*{_A}[│┃║]{_A}\s*$')
53
+
54
+
55
+ def _strip_inline_boxes_pair(content: str, ansi_content: str) -> tuple:
56
+ """去除 OutputBlock 内嵌的 box-drawing 框线,同步清理 content 和 ansi_content。
57
+
58
+ 仅在检测到完整 box(顶边框 + 底边框配对)时才去除,防止误伤普通 │ 内容。
59
+ 返回 (cleaned_content, cleaned_ansi_content)。
60
+ """
61
+ lines = content.split('\n')
62
+ top_stack: list = []
63
+ box_ranges: list = []
64
+ for i, line in enumerate(lines):
65
+ if _INLINE_BOX_TOP_RE.match(line):
66
+ top_stack.append(i)
67
+ elif _INLINE_BOX_BOTTOM_RE.match(line) and top_stack:
68
+ top_idx = top_stack.pop()
69
+ box_ranges.append((top_idx, i))
70
+
71
+ if not box_ranges:
72
+ return content, ansi_content
73
+
74
+ ansi_lines = ansi_content.split('\n')
75
+ if len(ansi_lines) != len(lines):
76
+ return content, ansi_content
77
+
78
+ remove_lines: set = set()
79
+ side_lines: set = set()
80
+ for top_idx, bottom_idx in box_ranges:
81
+ remove_lines.add(top_idx)
82
+ remove_lines.add(bottom_idx)
83
+ for j in range(top_idx + 1, bottom_idx):
84
+ side_lines.add(j)
85
+
86
+ result_content = []
87
+ result_ansi = []
88
+ for i, (line, aline) in enumerate(zip(lines, ansi_lines)):
89
+ if i in remove_lines:
90
+ continue
91
+ if i in side_lines:
92
+ line = _INLINE_BOX_LEFT_RE.sub(r'\1', line)
93
+ line = _INLINE_BOX_RIGHT_RE.sub('', line)
94
+ aline = _ANSI_BOX_LEFT_RE.sub(r'\1', aline)
95
+ aline = _ANSI_BOX_RIGHT_RE.sub('', aline)
96
+ result_content.append(line)
97
+ result_ansi.append(aline)
98
+
99
+ return '\n'.join(result_content), '\n'.join(result_ansi)
100
+
101
+
43
102
  # 编号选项行正则(权限确认对话框特征:❯ 1. Yes / 2. No 等)
44
103
  _NUMBERED_OPTION_RE = re.compile(r'^(?:❯\s*)?\d+[.)]\s+.+')
45
104
  # 带 ❯ 光标的编号选项行正则(锚点)
@@ -454,10 +513,27 @@ class ScreenParser:
454
513
  return output_rows, input_rows, bottom_rows
455
514
 
456
515
  def _trim_welcome(self, screen: pyte.Screen, rows: List[int]) -> List[int]:
457
- """去掉欢迎区域:跳过首列为空的前缀行,从第一个首列有内容的行开始"""
458
- for i, row in enumerate(rows):
459
- if _get_col0(screen, row).strip():
460
- return rows[i:]
516
+ """去掉欢迎区域:跳过首列为空的前缀行和欢迎框(Claude Code box)"""
517
+ i = 0
518
+ while i < len(rows):
519
+ col0 = _get_col0(screen, rows[i])
520
+ if not col0.strip():
521
+ i += 1
522
+ continue
523
+ # 首个非空 col0 是 box 顶角 → 检查是否为欢迎框
524
+ if col0 in BOX_CORNER_TOP:
525
+ first_line = _get_row_text(screen, rows[i])
526
+ if 'Claude Code' in first_line:
527
+ # 跳过整个欢迎框(到 ╰/└ 行为止)
528
+ i += 1
529
+ while i < len(rows):
530
+ if _get_col0(screen, rows[i]) in BOX_CORNER_BOTTOM:
531
+ i += 1
532
+ break
533
+ i += 1
534
+ continue # 继续跳过后续空行
535
+ # 非欢迎框,返回剩余行
536
+ return rows[i:]
461
537
  return []
462
538
 
463
539
  # ─── Step 2+3+4:输出区解析 ────────────────────────────────────────────
@@ -552,6 +628,7 @@ class ScreenParser:
552
628
  content = '\n'.join([first_content] + body_lines).rstrip()
553
629
  ansi_body_lines = [_get_row_ansi_text(screen, r) for r in block_rows[1:]]
554
630
  ansi_content = '\n'.join([cached_ansi_first] + ansi_body_lines).rstrip()
631
+ content, ansi_content = _strip_inline_boxes_pair(content, ansi_content)
555
632
  return OutputBlock(
556
633
  content=content, is_streaming=cached_blink, start_row=first_row,
557
634
  ansi_content=ansi_content, indicator=cached_ind, ansi_indicator=cached_ansi_ind,
@@ -602,6 +679,7 @@ class ScreenParser:
602
679
  body_lines = lines[1:]
603
680
  content = '\n'.join([first_content] + body_lines).rstrip()
604
681
  ansi_content = '\n'.join([ansi_first] + ansi_body).rstrip()
682
+ content, ansi_content = _strip_inline_boxes_pair(content, ansi_content)
605
683
  return OutputBlock(
606
684
  content=content, is_streaming=is_blink, start_row=first_row,
607
685
  ansi_content=ansi_content, indicator=ind, ansi_indicator=ansi_ind,
@@ -629,6 +707,8 @@ class ScreenParser:
629
707
  inner = stripped[:-1]
630
708
  content_lines.append(inner.rstrip())
631
709
  ansi_line = _get_row_ansi_text(screen, row, start_col=1)
710
+ # 去掉右侧 │(可能包裹 ANSI 码,如 │\x1b[0m 或 \x1b[...m│\x1b[0m)
711
+ ansi_line = re.sub(r'(\x1b\[[0-9;]*m)*[│┃║](\x1b\[0m)?\s*$', '', ansi_line)
632
712
  ansi_lines.append(ansi_line.rstrip())
633
713
 
634
714
  content = '\n'.join(content_lines).strip()
package/server/server.py CHANGED
@@ -119,6 +119,17 @@ class OutputWatcher:
119
119
  self.last_window: Optional[ClaudeWindow] = None
120
120
  # PTY 静止后延迟重刷:消除窗口平滑的延迟效应
121
121
  self._reflush_handle: Optional[asyncio.TimerHandle] = None
122
+ # 调试日志截断长度(可通过 ~/.remote-claude/.debug_config 配置)
123
+ self._debug_truncate_len = 80
124
+ try:
125
+ cfg_path = os.path.expanduser("~/.remote-claude/.debug_config")
126
+ if os.path.exists(cfg_path):
127
+ import json as _json
128
+ with open(cfg_path) as _f:
129
+ _cfg = _json.load(_f)
130
+ self._debug_truncate_len = int(_cfg.get("debug_truncate_len", 80))
131
+ except Exception:
132
+ pass
122
133
 
123
134
  def resize(self, cols: int, rows: int):
124
135
  """重建 renderer 以适应新尺寸,历史随之丢失(可接受)。
@@ -403,7 +414,7 @@ class OutputWatcher:
403
414
  else:
404
415
  lines.append(f"[{i}] UserInput: {block.text[:80]}")
405
416
  else:
406
- lines.append(f"[{i}] {type(block).__name__}: {str(block)[:80]}")
417
+ lines.append(f"[{i}] {type(block).__name__}: {str(block)[:self._debug_truncate_len]}")
407
418
  lines.append("")
408
419
  lines.append("-----")
409
420
  with open(self._debug_file, "w", encoding="utf-8") as f:
@@ -595,9 +606,32 @@ class ProxyServer:
595
606
 
596
607
  def _start_pty(self):
597
608
  """启动 PTY 并运行 Claude"""
598
- pid, fd = pty.fork()
609
+ # 加载环境变量快照(从 cmd_start 保存的快照文件恢复调用方 shell 的完整环境)
610
+ from utils.session import get_env_snapshot_path
611
+ import json as _json
612
+ env_snapshot_path = get_env_snapshot_path(self.session_name)
613
+ _extra_env = {}
614
+ try:
615
+ with open(env_snapshot_path) as _f:
616
+ _extra_env = _json.load(_f)
617
+ except Exception:
618
+ pass
619
+
620
+ try:
621
+ pid, fd = pty.fork()
622
+ except OSError:
623
+ env_snapshot_path.unlink(missing_ok=True)
624
+ raise
599
625
 
600
626
  if pid == 0:
627
+ # 子进程:恢复调用方 shell 的完整环境变量
628
+ for k, v in _extra_env.items():
629
+ os.environ[k] = v
630
+ # 环境已加载到内存,立即删除快照文件(execvp 前销毁)
631
+ try:
632
+ env_snapshot_path.unlink()
633
+ except Exception:
634
+ pass
601
635
  # 恢复 TERM 以支持 kitty keyboard protocol(Shift+Enter 等扩展键)
602
636
  # tmux 会将 TERM 改为 tmux-256color,导致 Claude CLI 不启用 kitty protocol
603
637
  os.environ['TERM'] = 'xterm-256color'
@@ -605,6 +639,7 @@ class ProxyServer:
605
639
  for key in ('TMUX', 'TMUX_PANE'):
606
640
  os.environ.pop(key, None)
607
641
  os.execvp("claude", ["claude"] + self.claude_args)
642
+ os._exit(1) # execvp 失败时兜底退出
608
643
  else:
609
644
  # 父进程
610
645
  self.master_fd = fd
package/utils/session.py CHANGED
@@ -60,6 +60,11 @@ def get_mq_path(session_name: str) -> Path:
60
60
  return SOCKET_DIR / f"{_safe_filename(session_name)}.mq"
61
61
 
62
62
 
63
+ def get_env_snapshot_path(session_name: str) -> Path:
64
+ """获取环境变量快照文件路径(cmd_start 写入,_start_pty 读取后删除)"""
65
+ return SOCKET_DIR / f"{_safe_filename(session_name)}_env.json"
66
+
67
+
63
68
  def ensure_socket_dir():
64
69
  """确保 socket 目录存在"""
65
70
  SOCKET_DIR.mkdir(parents=True, exist_ok=True)
@@ -248,6 +253,7 @@ def cleanup_session(session_name: str):
248
253
  sock_path.unlink(missing_ok=True)
249
254
  pid_file.unlink(missing_ok=True)
250
255
  get_mq_path(session_name).unlink(missing_ok=True)
256
+ get_env_snapshot_path(session_name).unlink(missing_ok=True)
251
257
 
252
258
 
253
259
  def is_session_active(session_name: str) -> bool: