remote-claude 1.0.3 → 1.0.5
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/.env.example +4 -0
- package/README.md +12 -136
- package/bin/cdx +9 -1
- package/bin/cl +9 -1
- package/bin/cla +9 -1
- package/bin/cx +9 -1
- package/bin/remote-claude +9 -23
- package/client/client.py +1 -1
- package/init.sh +30 -97
- package/lark_client/card_builder.py +53 -33
- package/lark_client/lark_handler.py +159 -42
- package/lark_client/main.py +6 -4
- package/lark_client/session_bridge.py +2 -2
- package/lark_client/setup_wizard.py +999 -0
- package/lark_client/shared_memory_poller.py +137 -112
- package/package.json +1 -1
- package/remote_claude.py +251 -0
- package/server/server.py +31 -19
- package/utils/session.py +81 -30
- package/scripts/check-env.sh +0 -43
|
@@ -202,8 +202,8 @@ class SharedMemoryPoller:
|
|
|
202
202
|
logger.error(f"_poll_once 异常: {e}", exc_info=True)
|
|
203
203
|
|
|
204
204
|
async def _poll_once(self, tracker: StreamTracker) -> None:
|
|
205
|
-
"""单次轮询:读取共享内存 → diff → 创建/更新卡片"""
|
|
206
|
-
#
|
|
205
|
+
"""单次轮询:读取共享内存 → diff → 创建/更新卡片 → 就绪通知"""
|
|
206
|
+
# 步骤 1:延迟初始化 Reader
|
|
207
207
|
if tracker.reader is None:
|
|
208
208
|
try:
|
|
209
209
|
from shared_state import get_mq_path, SharedStateReader
|
|
@@ -230,94 +230,114 @@ class SharedMemoryPoller:
|
|
|
230
230
|
agent_panel = state.get("agent_panel")
|
|
231
231
|
option_block = state.get("option_block")
|
|
232
232
|
cli_type = state.get("cli_type", "claude")
|
|
233
|
+
# timestamp 存在说明 server 已写入有效快照(即使内容全空,如 Codex 就绪等待输入)
|
|
234
|
+
has_valid_snapshot = state.get("timestamp") is not None
|
|
233
235
|
|
|
234
|
-
#
|
|
235
|
-
|
|
236
|
+
# 步骤 2:仅计算就绪状态,不发送通知
|
|
237
|
+
should_notify = self._update_ready_state(tracker, blocks, status_line, option_block, agent_panel)
|
|
236
238
|
|
|
239
|
+
# 步骤 3:卡片操作(含创建/更新/拆分)
|
|
240
|
+
await self._do_card_update(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type, has_valid_snapshot=has_valid_snapshot)
|
|
241
|
+
|
|
242
|
+
# 步骤 4:通知在卡片操作之后发送,确保新卡先出现
|
|
243
|
+
if should_notify:
|
|
244
|
+
await self._send_ready_notification(tracker, cli_type)
|
|
245
|
+
|
|
246
|
+
async def _do_card_update(
|
|
247
|
+
self, tracker: StreamTracker, blocks: List[dict],
|
|
248
|
+
status_line: Optional[dict], bottom_bar: Optional[dict],
|
|
249
|
+
agent_panel: Optional[dict], option_block: Optional[dict],
|
|
250
|
+
cli_type: str,
|
|
251
|
+
has_valid_snapshot: bool = False,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""卡片操作主体:获取活跃卡片 → 创建/更新/拆分"""
|
|
237
254
|
# 获取活跃卡片(最后一张且未冻结)
|
|
238
255
|
active = None
|
|
239
256
|
if tracker.cards and not tracker.cards[-1].frozen:
|
|
240
257
|
active = tracker.cards[-1]
|
|
241
258
|
|
|
242
259
|
if not blocks and not status_line and not bottom_bar and not agent_panel and not option_block and active is None:
|
|
243
|
-
|
|
260
|
+
if not has_valid_snapshot:
|
|
261
|
+
return # 真的还没有快照,不创建卡片
|
|
262
|
+
# 有效快照但内容全空(如 Codex 就绪等待输入),继续创建就绪卡片
|
|
244
263
|
|
|
245
264
|
if active is None:
|
|
246
265
|
# 需要创建新卡片
|
|
247
266
|
await self._create_new_card(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type=cli_type)
|
|
248
|
-
|
|
249
|
-
# 有活跃卡片,检查是否需要更新
|
|
250
|
-
blocks_slice = blocks[active.start_idx:]
|
|
267
|
+
return
|
|
251
268
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
logger.warning(
|
|
255
|
-
f"[blocks regression] len(blocks)={len(blocks)} < start_idx={active.start_idx}, "
|
|
256
|
-
f"resetting start_idx to 0 (session={tracker.session_name})"
|
|
257
|
-
)
|
|
258
|
-
active.start_idx = 0
|
|
259
|
-
blocks_slice = blocks[0:]
|
|
260
|
-
tracker.content_hash = "" # 强制刷新
|
|
269
|
+
# 有活跃卡片,检查是否需要更新
|
|
270
|
+
blocks_slice = blocks[active.start_idx:]
|
|
261
271
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
272
|
+
# blocks 骤降检测(compact/重启导致 blocks 从头累积)
|
|
273
|
+
if len(blocks) < active.start_idx:
|
|
274
|
+
logger.warning(
|
|
275
|
+
f"[blocks regression] len(blocks)={len(blocks)} < start_idx={active.start_idx}, "
|
|
276
|
+
f"resetting start_idx to 0 (session={tracker.session_name})"
|
|
277
|
+
)
|
|
278
|
+
active.start_idx = 0
|
|
279
|
+
blocks_slice = blocks[0:]
|
|
280
|
+
tracker.content_hash = "" # 强制刷新
|
|
266
281
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
282
|
+
# 超限检查
|
|
283
|
+
if len(blocks_slice) > MAX_CARD_BLOCKS:
|
|
284
|
+
await self._freeze_and_split(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type=cli_type)
|
|
285
|
+
return
|
|
271
286
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
287
|
+
# hash diff
|
|
288
|
+
new_hash = self._compute_hash(blocks_slice, status_line, bottom_bar, agent_panel, option_block)
|
|
289
|
+
if new_hash == tracker.content_hash:
|
|
290
|
+
return # 无变化
|
|
275
291
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
freeze_count = self._find_freeze_count(blocks_slice, tracker.session_name)
|
|
280
|
-
await self._freeze_and_split(
|
|
281
|
-
tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
|
|
282
|
-
cli_type=cli_type, freeze_count=freeze_count,
|
|
283
|
-
)
|
|
284
|
-
return
|
|
292
|
+
# 更新卡片
|
|
293
|
+
from .card_builder import build_stream_card
|
|
294
|
+
card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
|
|
285
295
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
296
|
+
# 大小超限检查(与 blocks 数量超限同一套逻辑)
|
|
297
|
+
card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
|
|
298
|
+
if card_size > CARD_SIZE_LIMIT:
|
|
299
|
+
freeze_count = self._find_freeze_count(blocks_slice, tracker.session_name)
|
|
300
|
+
await self._freeze_and_split(
|
|
301
|
+
tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
|
|
302
|
+
cli_type=cli_type, freeze_count=freeze_count,
|
|
291
303
|
)
|
|
304
|
+
return
|
|
292
305
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return
|
|
300
|
-
elif not success:
|
|
301
|
-
# 降级:创建新卡片替代
|
|
302
|
-
logger.warning(
|
|
303
|
-
f"update_card 失败 card_id={active.card_id} seq={active.sequence},降级为新卡片"
|
|
304
|
-
)
|
|
305
|
-
_track_stats('card', 'fallback', session_name=tracker.session_name,
|
|
306
|
-
chat_id=tracker.chat_id)
|
|
307
|
-
new_card_id = await self._card_service.create_card(card_dict)
|
|
308
|
-
if new_card_id:
|
|
309
|
-
await self._card_service.send_card(tracker.chat_id, new_card_id)
|
|
310
|
-
active.card_id = new_card_id
|
|
311
|
-
active.sequence = 0
|
|
312
|
-
else:
|
|
313
|
-
_track_stats('card', 'update', session_name=tracker.session_name,
|
|
314
|
-
chat_id=tracker.chat_id)
|
|
306
|
+
active.sequence += 1
|
|
307
|
+
success = await self._card_service.update_card(
|
|
308
|
+
card_id=active.card_id,
|
|
309
|
+
sequence=active.sequence,
|
|
310
|
+
card_content=card_dict,
|
|
311
|
+
)
|
|
315
312
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
313
|
+
if getattr(success, 'is_element_limit', False):
|
|
314
|
+
# 元素超限:冻结旧卡 + 推新流式卡
|
|
315
|
+
await self._handle_element_limit(
|
|
316
|
+
tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
|
|
317
|
+
cli_type=cli_type,
|
|
318
|
+
)
|
|
319
|
+
return
|
|
320
|
+
elif not success:
|
|
321
|
+
# 降级:创建新卡片替代
|
|
322
|
+
logger.warning(
|
|
323
|
+
f"update_card 失败 card_id={active.card_id} seq={active.sequence},降级为新卡片"
|
|
320
324
|
)
|
|
325
|
+
_track_stats('card', 'fallback', session_name=tracker.session_name,
|
|
326
|
+
chat_id=tracker.chat_id)
|
|
327
|
+
new_card_id = await self._card_service.create_card(card_dict)
|
|
328
|
+
if new_card_id:
|
|
329
|
+
await self._card_service.send_card(tracker.chat_id, new_card_id)
|
|
330
|
+
active.card_id = new_card_id
|
|
331
|
+
active.sequence = 0
|
|
332
|
+
else:
|
|
333
|
+
_track_stats('card', 'update', session_name=tracker.session_name,
|
|
334
|
+
chat_id=tracker.chat_id)
|
|
335
|
+
|
|
336
|
+
tracker.content_hash = new_hash
|
|
337
|
+
logger.debug(
|
|
338
|
+
f"[UPDATE] session={tracker.session_name} blocks={len(blocks_slice)} "
|
|
339
|
+
f"seq={active.sequence} hash={new_hash[:8]}"
|
|
340
|
+
)
|
|
321
341
|
|
|
322
342
|
async def _create_new_card(
|
|
323
343
|
self, tracker: StreamTracker, blocks: List[dict],
|
|
@@ -342,8 +362,8 @@ class SharedMemoryPoller:
|
|
|
342
362
|
)
|
|
343
363
|
|
|
344
364
|
blocks_slice = blocks[start_idx:]
|
|
345
|
-
|
|
346
|
-
|
|
365
|
+
# 注意:不在此处提前 return,上层 _do_card_update 已做过滤,
|
|
366
|
+
# 走到这里说明确实需要创建卡片(如 Codex 就绪等待输入的空内容卡片)
|
|
347
367
|
|
|
348
368
|
from .card_builder import build_stream_card
|
|
349
369
|
card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
|
|
@@ -487,53 +507,57 @@ class SharedMemoryPoller:
|
|
|
487
507
|
)
|
|
488
508
|
tracker.last_notify_message_id = None
|
|
489
509
|
|
|
490
|
-
|
|
510
|
+
def _update_ready_state(
|
|
491
511
|
self, tracker: StreamTracker,
|
|
492
512
|
blocks: list, status_line: Optional[dict], option_block: Optional[dict],
|
|
493
|
-
|
|
494
|
-
) ->
|
|
495
|
-
"""
|
|
496
|
-
current_ready = _is_ready(blocks, status_line, option_block)
|
|
513
|
+
agent_panel: Optional[dict] = None,
|
|
514
|
+
) -> bool:
|
|
515
|
+
"""更新就绪状态,返回是否需要发送就绪通知(不执行发送)"""
|
|
516
|
+
current_ready = _is_ready(blocks, status_line, option_block, agent_panel)
|
|
497
517
|
prev_ready = tracker.prev_is_ready
|
|
498
518
|
tracker.prev_is_ready = current_ready
|
|
519
|
+
return current_ready and not prev_ready and tracker.is_group and _notify_enabled
|
|
499
520
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
except Exception as e:
|
|
526
|
-
logger.warning(f"加急通知失败: {e}")
|
|
527
|
-
else:
|
|
528
|
-
# 首次通知(或无法加急时)→ 发新消息,记录 message_id
|
|
529
|
-
label = "所有人" if uid == "all" else ""
|
|
530
|
-
text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
|
|
531
|
-
try:
|
|
521
|
+
async def _send_ready_notification(
|
|
522
|
+
self, tracker: StreamTracker, cli_type: str = "claude"
|
|
523
|
+
) -> None:
|
|
524
|
+
"""发送就绪通知(加急或新消息),应在卡片操作完成后调用"""
|
|
525
|
+
count = _increment_ready_count()
|
|
526
|
+
uid = tracker.notify_user_id or "all"
|
|
527
|
+
cli_name = "Claude" if cli_type == "claude" else "Codex"
|
|
528
|
+
logger.info(f"就绪提醒: chat_id={tracker.chat_id[:8]}..., count={count}, uid={uid}, "
|
|
529
|
+
f"last_msg={'有' if tracker.last_notify_message_id else '无'}")
|
|
530
|
+
|
|
531
|
+
if tracker.last_notify_message_id and uid != "all" and _urgent_enabled:
|
|
532
|
+
# 已有通知消息 + 加急开关开启 → 尝试加急
|
|
533
|
+
try:
|
|
534
|
+
ok = await self._card_service.send_urgent_app(
|
|
535
|
+
tracker.last_notify_message_id, [uid]
|
|
536
|
+
)
|
|
537
|
+
if ok:
|
|
538
|
+
# 加急成功 → 5 秒后自动取消
|
|
539
|
+
asyncio.create_task(self._cancel_urgent_later(
|
|
540
|
+
tracker.last_notify_message_id, [uid], delay=5
|
|
541
|
+
))
|
|
542
|
+
else:
|
|
543
|
+
# 加急失败(权限未开通等)→ 降级发新消息
|
|
544
|
+
label = ""
|
|
545
|
+
text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
|
|
532
546
|
msg_id = await self._card_service.send_text(tracker.chat_id, text)
|
|
533
547
|
if msg_id:
|
|
534
548
|
tracker.last_notify_message_id = msg_id
|
|
535
|
-
|
|
536
|
-
|
|
549
|
+
except Exception as e:
|
|
550
|
+
logger.warning(f"加急通知失败: {e}")
|
|
551
|
+
else:
|
|
552
|
+
# 首次通知(或无法加急时)→ 发新消息,记录 message_id
|
|
553
|
+
label = "所有人" if uid == "all" else ""
|
|
554
|
+
text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
|
|
555
|
+
try:
|
|
556
|
+
msg_id = await self._card_service.send_text(tracker.chat_id, text)
|
|
557
|
+
if msg_id:
|
|
558
|
+
tracker.last_notify_message_id = msg_id
|
|
559
|
+
except Exception as e:
|
|
560
|
+
logger.warning(f"就绪提醒发送失败: {e}")
|
|
537
561
|
|
|
538
562
|
async def _cancel_urgent_later(self, message_id: str, user_ids: list, delay: float = 15) -> None:
|
|
539
563
|
"""延迟取消加急通知"""
|
|
@@ -597,10 +621,11 @@ class SharedMemoryPoller:
|
|
|
597
621
|
|
|
598
622
|
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
|
|
599
623
|
|
|
600
|
-
def _is_ready(blocks: list, status_line: Optional[dict], option_block: Optional[dict]) -> bool:
|
|
601
|
-
"""数据层就绪判断:无 streaming block、无 status_line(option_block 不影响就绪)"""
|
|
624
|
+
def _is_ready(blocks: list, status_line: Optional[dict], option_block: Optional[dict], agent_panel: Optional[dict] = None) -> bool:
|
|
625
|
+
"""数据层就绪判断:无 streaming block、无 status_line、无后台 agent(option_block 不影响就绪)"""
|
|
602
626
|
has_streaming = any(b.get("is_streaming", False) for b in blocks)
|
|
603
|
-
|
|
627
|
+
has_agents = agent_panel is not None
|
|
628
|
+
return not has_streaming and status_line is None and not has_agents
|
|
604
629
|
|
|
605
630
|
|
|
606
631
|
_READY_COUNT_FILE = USER_DATA_DIR / "ready_notify_count"
|
package/package.json
CHANGED
package/remote_claude.py
CHANGED
|
@@ -252,6 +252,44 @@ def cmd_status(args):
|
|
|
252
252
|
return 0
|
|
253
253
|
|
|
254
254
|
|
|
255
|
+
WATCHDOG_SCRIPT = USER_DATA_DIR / "watchdog.sh"
|
|
256
|
+
WATCHDOG_PID_FILE = USER_DATA_DIR / "watchdog.pid"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _start_watchdog():
|
|
260
|
+
"""启动后台 watchdog(如果尚未运行)"""
|
|
261
|
+
if not WATCHDOG_SCRIPT.exists():
|
|
262
|
+
return # 脚本不存在时静默跳过
|
|
263
|
+
# 检查是否已在运行
|
|
264
|
+
if WATCHDOG_PID_FILE.exists():
|
|
265
|
+
try:
|
|
266
|
+
pid = int(WATCHDOG_PID_FILE.read_text().strip())
|
|
267
|
+
os.kill(pid, 0)
|
|
268
|
+
return # 已在运行
|
|
269
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
270
|
+
pass
|
|
271
|
+
process = subprocess.Popen(
|
|
272
|
+
["bash", str(WATCHDOG_SCRIPT)],
|
|
273
|
+
stdout=subprocess.DEVNULL, # watchdog 通过 tee 自己写 $LOG,不需要 stdout 捕获
|
|
274
|
+
stderr=subprocess.DEVNULL,
|
|
275
|
+
start_new_session=True,
|
|
276
|
+
)
|
|
277
|
+
print(f" watchdog: 已启动 (PID: {process.pid})")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _stop_watchdog():
|
|
281
|
+
"""停止后台 watchdog"""
|
|
282
|
+
if not WATCHDOG_PID_FILE.exists():
|
|
283
|
+
return
|
|
284
|
+
try:
|
|
285
|
+
pid = int(WATCHDOG_PID_FILE.read_text().strip())
|
|
286
|
+
os.kill(pid, signal.SIGTERM)
|
|
287
|
+
print(f" watchdog: 已停止 (PID: {pid})")
|
|
288
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
289
|
+
pass
|
|
290
|
+
WATCHDOG_PID_FILE.unlink(missing_ok=True)
|
|
291
|
+
|
|
292
|
+
|
|
255
293
|
def cmd_lark_start(args):
|
|
256
294
|
"""启动飞书客户端(守护进程)"""
|
|
257
295
|
if is_lark_running():
|
|
@@ -296,6 +334,7 @@ def cmd_lark_start(args):
|
|
|
296
334
|
print(f" 日志: {log_file}")
|
|
297
335
|
print(f"\n使用 'python3 remote_claude.py lark status' 查看状态")
|
|
298
336
|
print(f"使用 'python3 remote_claude.py lark stop' 停止")
|
|
337
|
+
_start_watchdog()
|
|
299
338
|
return 0
|
|
300
339
|
else:
|
|
301
340
|
print("✗ 启动失败,请查看日志:")
|
|
@@ -342,6 +381,7 @@ def cmd_lark_stop(args):
|
|
|
342
381
|
if not is_lark_running():
|
|
343
382
|
print("✓ 飞书客户端已停止")
|
|
344
383
|
cleanup_lark()
|
|
384
|
+
_stop_watchdog()
|
|
345
385
|
return 0
|
|
346
386
|
else:
|
|
347
387
|
print("✗ 无法停止进程,请手动终止:")
|
|
@@ -351,6 +391,7 @@ def cmd_lark_stop(args):
|
|
|
351
391
|
except ProcessLookupError:
|
|
352
392
|
print("进程已不存在,清理残留文件")
|
|
353
393
|
cleanup_lark()
|
|
394
|
+
_stop_watchdog()
|
|
354
395
|
return 0
|
|
355
396
|
except Exception as e:
|
|
356
397
|
print(f"✗ 停止失败: {e}")
|
|
@@ -477,6 +518,198 @@ def cmd_update(args):
|
|
|
477
518
|
return 0
|
|
478
519
|
|
|
479
520
|
|
|
521
|
+
def cmd_deps(args):
|
|
522
|
+
"""检查并安装依赖"""
|
|
523
|
+
import shutil
|
|
524
|
+
|
|
525
|
+
YELLOW = "\033[33m"
|
|
526
|
+
GREEN = "\033[32m"
|
|
527
|
+
RED = "\033[31m"
|
|
528
|
+
RESET = "\033[0m"
|
|
529
|
+
|
|
530
|
+
def print_ok(msg):
|
|
531
|
+
print(f"{GREEN}✓{RESET} {msg}")
|
|
532
|
+
|
|
533
|
+
def print_warn(msg):
|
|
534
|
+
print(f"{YELLOW}⚠{RESET} {msg}")
|
|
535
|
+
|
|
536
|
+
def print_err(msg):
|
|
537
|
+
print(f"{RED}✗{RESET} {msg}")
|
|
538
|
+
|
|
539
|
+
print(f"\n{GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}")
|
|
540
|
+
print(f"{GREEN} 依赖检查{RESET}")
|
|
541
|
+
print(f"{GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}\n")
|
|
542
|
+
|
|
543
|
+
# 检查 uv
|
|
544
|
+
uv_path = shutil.which("uv")
|
|
545
|
+
if uv_path:
|
|
546
|
+
r = subprocess.run(["uv", "--version"], capture_output=True, text=True)
|
|
547
|
+
print_ok(f"uv: {r.stdout.strip()}")
|
|
548
|
+
else:
|
|
549
|
+
print_err("uv: 未安装")
|
|
550
|
+
|
|
551
|
+
# 检查 claude CLI
|
|
552
|
+
claude_path = shutil.which("claude")
|
|
553
|
+
if claude_path:
|
|
554
|
+
print_ok("Claude CLI: 已安装")
|
|
555
|
+
else:
|
|
556
|
+
print_warn("Claude CLI: 未安装")
|
|
557
|
+
|
|
558
|
+
# 检查 codex CLI
|
|
559
|
+
codex_path = shutil.which("codex")
|
|
560
|
+
if codex_path:
|
|
561
|
+
print_ok("Codex CLI: 已安装")
|
|
562
|
+
else:
|
|
563
|
+
print_warn("Codex CLI: 未安装(可选)")
|
|
564
|
+
|
|
565
|
+
# 检查 tmux
|
|
566
|
+
REQUIRED_MAJOR = 3
|
|
567
|
+
REQUIRED_MINOR = 6
|
|
568
|
+
|
|
569
|
+
tmux_path = shutil.which("tmux")
|
|
570
|
+
tmux_ok = False
|
|
571
|
+
if tmux_path:
|
|
572
|
+
r = subprocess.run(["tmux", "-V"], capture_output=True, text=True)
|
|
573
|
+
ver_str = r.stdout.strip().split()[-1] if r.stdout.strip() else "0.0"
|
|
574
|
+
parts = ver_str.replace("a", "").replace("b", "").replace("c", "").split(".")
|
|
575
|
+
try:
|
|
576
|
+
major = int(parts[0])
|
|
577
|
+
minor = int(parts[1]) if len(parts) > 1 else 0
|
|
578
|
+
except ValueError:
|
|
579
|
+
major, minor = 0, 0
|
|
580
|
+
if major > REQUIRED_MAJOR or (major == REQUIRED_MAJOR and minor >= REQUIRED_MINOR):
|
|
581
|
+
print_ok(f"tmux: {r.stdout.strip()}(满足 >= {REQUIRED_MAJOR}.{REQUIRED_MINOR})")
|
|
582
|
+
tmux_ok = True
|
|
583
|
+
else:
|
|
584
|
+
print_warn(f"tmux: {r.stdout.strip()}(需要 >= {REQUIRED_MAJOR}.{REQUIRED_MINOR})")
|
|
585
|
+
else:
|
|
586
|
+
print_err("tmux: 未安装")
|
|
587
|
+
|
|
588
|
+
if tmux_ok:
|
|
589
|
+
print(f"\n{GREEN}所有关键依赖已满足。{RESET}")
|
|
590
|
+
return 0
|
|
591
|
+
|
|
592
|
+
# tmux 版本不满足,提供源码编译安装
|
|
593
|
+
print(f"\n{YELLOW}tmux 版本不满足要求,是否从源码编译安装 tmux 3.6a?{RESET}")
|
|
594
|
+
print(f" 安装位置: $HOME/.local(不需要 root 权限)")
|
|
595
|
+
print(f" 编译依赖安装可能需要 sudo 密码\n")
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
answer = input("继续?[y/N]: ").strip().lower()
|
|
599
|
+
except (EOFError, KeyboardInterrupt):
|
|
600
|
+
print()
|
|
601
|
+
return 0
|
|
602
|
+
|
|
603
|
+
if answer not in ("y", "yes"):
|
|
604
|
+
print("已跳过 tmux 安装。")
|
|
605
|
+
return 0
|
|
606
|
+
|
|
607
|
+
# 安装编译依赖
|
|
608
|
+
print(f"\n{YELLOW}[1/4] 安装编译依赖...{RESET}")
|
|
609
|
+
os_name = os.uname().sysname
|
|
610
|
+
if os_name == "Darwin":
|
|
611
|
+
subprocess.run(["brew", "install", "libevent", "ncurses", "pkg-config", "bison"], check=False)
|
|
612
|
+
elif os_name == "Linux":
|
|
613
|
+
if shutil.which("apt-get"):
|
|
614
|
+
subprocess.run(["sudo", "apt-get", "update"], check=False)
|
|
615
|
+
subprocess.run(["sudo", "apt-get", "install", "-y",
|
|
616
|
+
"build-essential", "libevent-dev", "libncurses5-dev",
|
|
617
|
+
"libncursesw5-dev", "bison", "pkg-config"], check=False)
|
|
618
|
+
elif shutil.which("yum"):
|
|
619
|
+
subprocess.run(["sudo", "yum", "groupinstall", "-y", "Development Tools"], check=False)
|
|
620
|
+
subprocess.run(["sudo", "yum", "install", "-y",
|
|
621
|
+
"libevent-devel", "ncurses-devel", "bison"], check=False)
|
|
622
|
+
else:
|
|
623
|
+
print_warn("无法识别包管理器,请手动安装编译依赖: libevent-dev ncurses-dev bison pkg-config")
|
|
624
|
+
|
|
625
|
+
# 下载源码
|
|
626
|
+
print(f"\n{YELLOW}[2/4] 下载 tmux 3.6a 源码...{RESET}")
|
|
627
|
+
import tempfile
|
|
628
|
+
tmpdir = tempfile.mkdtemp()
|
|
629
|
+
tarball = os.path.join(tmpdir, "tmux.tar.gz")
|
|
630
|
+
tmux_url = "https://github.com/tmux/tmux/releases/download/3.6a/tmux-3.6a.tar.gz"
|
|
631
|
+
|
|
632
|
+
r = subprocess.run(["curl", "-fsSL", tmux_url, "-o", tarball])
|
|
633
|
+
if r.returncode != 0:
|
|
634
|
+
print_err("下载失败,请检查网络连接。")
|
|
635
|
+
return 1
|
|
636
|
+
|
|
637
|
+
subprocess.run(["tar", "-xzf", tarball, "-C", tmpdir], check=True)
|
|
638
|
+
src_dir = os.path.join(tmpdir, "tmux-3.6a")
|
|
639
|
+
|
|
640
|
+
# 编译
|
|
641
|
+
prefix = os.path.join(os.path.expanduser("~"), ".local")
|
|
642
|
+
print(f"\n{YELLOW}[3/4] 编译 tmux(安装到 {prefix})...{RESET}")
|
|
643
|
+
|
|
644
|
+
nproc = "2"
|
|
645
|
+
try:
|
|
646
|
+
r = subprocess.run(["nproc"], capture_output=True, text=True)
|
|
647
|
+
if r.returncode == 0:
|
|
648
|
+
nproc = r.stdout.strip()
|
|
649
|
+
except FileNotFoundError:
|
|
650
|
+
pass
|
|
651
|
+
|
|
652
|
+
r = subprocess.run(
|
|
653
|
+
f"./configure --prefix={prefix} && make -j{nproc} && make install",
|
|
654
|
+
shell=True, cwd=src_dir
|
|
655
|
+
)
|
|
656
|
+
if r.returncode != 0:
|
|
657
|
+
print_err("编译失败,请检查编译依赖是否已安装。")
|
|
658
|
+
return 1
|
|
659
|
+
|
|
660
|
+
# 清理临时目录
|
|
661
|
+
import shutil as _shutil
|
|
662
|
+
_shutil.rmtree(tmpdir, ignore_errors=True)
|
|
663
|
+
|
|
664
|
+
# 配置 PATH
|
|
665
|
+
print(f"\n{YELLOW}[4/4] 配置 PATH...{RESET}")
|
|
666
|
+
local_bin = os.path.join(prefix, "bin")
|
|
667
|
+
os.environ["PATH"] = f"{local_bin}:{os.environ.get('PATH', '')}"
|
|
668
|
+
|
|
669
|
+
if f"{local_bin}" not in os.environ.get("PATH", ""):
|
|
670
|
+
os.environ["PATH"] = f"{local_bin}:{os.environ['PATH']}"
|
|
671
|
+
|
|
672
|
+
# 写入 shell rc
|
|
673
|
+
shell_name = os.path.basename(os.environ.get("SHELL", "bash"))
|
|
674
|
+
rc_file = os.path.join(os.path.expanduser("~"), ".zshrc" if shell_name == "zsh" else ".bashrc")
|
|
675
|
+
path_line = 'export PATH="$HOME/.local/bin:$PATH"'
|
|
676
|
+
try:
|
|
677
|
+
rc_content = open(rc_file).read() if os.path.exists(rc_file) else ""
|
|
678
|
+
if "$HOME/.local/bin" not in rc_content:
|
|
679
|
+
with open(rc_file, "a") as f:
|
|
680
|
+
f.write(f"\n# remote-claude: tmux 路径\n{path_line}\n")
|
|
681
|
+
print_ok(f"已将 $HOME/.local/bin 写入 {rc_file}")
|
|
682
|
+
except Exception as e:
|
|
683
|
+
print_warn(f"无法写入 {rc_file}: {e}")
|
|
684
|
+
|
|
685
|
+
# 验证
|
|
686
|
+
tmux_bin = os.path.join(local_bin, "tmux")
|
|
687
|
+
if os.path.exists(tmux_bin):
|
|
688
|
+
r = subprocess.run([tmux_bin, "-V"], capture_output=True, text=True)
|
|
689
|
+
print(f"\n{GREEN}✓ tmux 安装成功: {r.stdout.strip()}{RESET}")
|
|
690
|
+
print(f" 路径: {tmux_bin}")
|
|
691
|
+
print(f" 请运行 source {rc_file} 或重新打开终端使 PATH 生效。")
|
|
692
|
+
else:
|
|
693
|
+
print_err("安装似乎未成功,请检查上方输出。")
|
|
694
|
+
return 1
|
|
695
|
+
|
|
696
|
+
return 0
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def cmd_lark_init(args):
|
|
700
|
+
"""飞书机器人配置向导(扫码自动创建应用)"""
|
|
701
|
+
from lark_client.setup_wizard import SetupWizard
|
|
702
|
+
check_only = getattr(args, "check", False)
|
|
703
|
+
new_only = getattr(args, "new", False)
|
|
704
|
+
wizard = SetupWizard(check_only=check_only, new_only=new_only)
|
|
705
|
+
rc = wizard.run()
|
|
706
|
+
# 配置成功后自动重启飞书客户端(--check / --new 模式跳过)
|
|
707
|
+
if rc == 0 and not check_only and not new_only:
|
|
708
|
+
print("\n正在重启飞书客户端以应用新配置...")
|
|
709
|
+
cmd_lark_restart(args)
|
|
710
|
+
return rc
|
|
711
|
+
|
|
712
|
+
|
|
480
713
|
def cmd_lark(args):
|
|
481
714
|
"""飞书客户端管理(兼容旧命令)"""
|
|
482
715
|
# 如果没有子命令,默认显示状态或启动
|
|
@@ -485,6 +718,7 @@ def cmd_lark(args):
|
|
|
485
718
|
else:
|
|
486
719
|
print("飞书客户端未运行")
|
|
487
720
|
print("\n可用命令:")
|
|
721
|
+
print(" python3 remote_claude.py lark init - 配置向导(首次使用)")
|
|
488
722
|
print(" python3 remote_claude.py lark start - 启动客户端")
|
|
489
723
|
print(" python3 remote_claude.py lark stop - 停止客户端")
|
|
490
724
|
print(" python3 remote_claude.py lark restart - 重启客户端")
|
|
@@ -506,6 +740,9 @@ def main():
|
|
|
506
740
|
%(prog)s status mywork 显示 mywork 会话状态
|
|
507
741
|
|
|
508
742
|
飞书客户端:
|
|
743
|
+
%(prog)s lark init 配置向导(首次使用,扫码自动创建应用)
|
|
744
|
+
%(prog)s lark init --check 检查当前配置状态
|
|
745
|
+
%(prog)s lark init --new 扫码创建新应用(不修改已有配置)
|
|
509
746
|
%(prog)s lark start 启动飞书客户端
|
|
510
747
|
%(prog)s lark stop 停止飞书客户端
|
|
511
748
|
%(prog)s lark restart 重启飞书客户端
|
|
@@ -529,6 +766,9 @@ def main():
|
|
|
529
766
|
|
|
530
767
|
更新:
|
|
531
768
|
%(prog)s update 更新到最新版本
|
|
769
|
+
|
|
770
|
+
依赖管理:
|
|
771
|
+
%(prog)s deps 检查依赖并安装(含 tmux 源码编译)
|
|
532
772
|
"""
|
|
533
773
|
)
|
|
534
774
|
|
|
@@ -605,6 +845,13 @@ def main():
|
|
|
605
845
|
lark_status_parser = lark_subparsers.add_parser("status", help="查看飞书客户端状态")
|
|
606
846
|
lark_status_parser.set_defaults(func=cmd_lark_status)
|
|
607
847
|
|
|
848
|
+
# lark init
|
|
849
|
+
lark_init_parser = lark_subparsers.add_parser("init", help="配置向导(扫码自动创建应用)")
|
|
850
|
+
lark_init_group = lark_init_parser.add_mutually_exclusive_group()
|
|
851
|
+
lark_init_group.add_argument("--check", action="store_true", help="仅检查当前配置状态")
|
|
852
|
+
lark_init_group.add_argument("--new", action="store_true", help="扫码创建新应用(不修改已有配置)")
|
|
853
|
+
lark_init_parser.set_defaults(func=cmd_lark_init)
|
|
854
|
+
|
|
608
855
|
# 如果只输入 lark 没有子命令,使用默认处理
|
|
609
856
|
lark_parser.set_defaults(func=cmd_lark)
|
|
610
857
|
|
|
@@ -636,6 +883,10 @@ def main():
|
|
|
636
883
|
update_parser = subparsers.add_parser("update", help="更新 remote-claude 到最新版本")
|
|
637
884
|
update_parser.set_defaults(func=cmd_update)
|
|
638
885
|
|
|
886
|
+
# deps 命令
|
|
887
|
+
deps_parser = subparsers.add_parser("deps", help="检查并安装依赖(tmux 源码编译等)")
|
|
888
|
+
deps_parser.set_defaults(func=cmd_deps)
|
|
889
|
+
|
|
639
890
|
args, remaining = parser.parse_known_args()
|
|
640
891
|
|
|
641
892
|
if args.command is None:
|