remote-claude 0.1.0

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/stats/query.py ADDED
@@ -0,0 +1,151 @@
1
+ """
2
+ StatsQuery:本地统计查询 + CLI 格式化输出
3
+ """
4
+
5
+ import sqlite3
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ _DB_PATH = Path.home() / ".local" / "share" / "remote-claude" / "stats.db"
11
+
12
+
13
+ def _get_conn() -> Optional[sqlite3.Connection]:
14
+ if not _DB_PATH.exists():
15
+ return None
16
+ try:
17
+ return sqlite3.connect(str(_DB_PATH))
18
+ except Exception:
19
+ return None
20
+
21
+
22
+ def _date_range(range_str: str) -> tuple[str, str]:
23
+ """解析 range 字符串,返回 (start_date, end_date)"""
24
+ end_date = time.strftime('%Y-%m-%d')
25
+ if range_str == 'today' or range_str is None:
26
+ start_date = end_date
27
+ elif range_str.endswith('d'):
28
+ days = int(range_str[:-1])
29
+ start_date = time.strftime(
30
+ '%Y-%m-%d', time.localtime(time.time() - days * 86400)
31
+ )
32
+ elif range_str.endswith('m'):
33
+ months = int(range_str[:-1])
34
+ start_date = time.strftime(
35
+ '%Y-%m-%d', time.localtime(time.time() - months * 30 * 86400)
36
+ )
37
+ else:
38
+ start_date = end_date
39
+ return start_date, end_date
40
+
41
+
42
+ def _fmt_num(n: int) -> str:
43
+ """格式化数字(千分位逗号)"""
44
+ return f"{n:,}"
45
+
46
+
47
+ def query_summary(range_str: str = 'today', session_name: str = '',
48
+ detail: bool = False) -> str:
49
+ """生成统计摘要字符串"""
50
+ conn = _get_conn()
51
+ if conn is None:
52
+ return "暂无统计数据(数据库未初始化)"
53
+
54
+ start_date, end_date = _date_range(range_str)
55
+
56
+ # 构建查询条件
57
+ where = "WHERE date >= ? AND date <= ?"
58
+ params: list = [start_date, end_date]
59
+ if session_name:
60
+ where += " AND session_name = ?"
61
+ params.append(session_name)
62
+
63
+ def _sum(category: str, event: str) -> int:
64
+ row = conn.execute(
65
+ f"SELECT COALESCE(SUM(value), 0) FROM events "
66
+ f"{where} AND category=? AND event=?",
67
+ params + [category, event]
68
+ ).fetchone()
69
+ return row[0] if row else 0
70
+
71
+ def _count(category: str, event: str) -> int:
72
+ row = conn.execute(
73
+ f"SELECT COUNT(*) FROM events "
74
+ f"{where} AND category=? AND event=?",
75
+ params + [category, event]
76
+ ).fetchone()
77
+ return row[0] if row else 0
78
+
79
+ # 收集各类指标
80
+ sess_start = _count('session', 'start')
81
+ sess_attach = _count('session', 'attach')
82
+ sess_end = _count('session', 'end')
83
+
84
+ term_connect = _count('terminal', 'connect')
85
+ term_input = _count('terminal', 'input')
86
+ term_disconnect = _count('terminal', 'disconnect')
87
+
88
+ lark_msg = _count('lark', 'message')
89
+ lark_opt = _count('lark', 'option_select')
90
+ lark_key = _count('lark', 'raw_key')
91
+ lark_cmd = _count('lark', 'cmd')
92
+ lark_attach = _count('lark', 'attach')
93
+ lark_cmd_start = _count('lark', 'cmd_start')
94
+
95
+ card_create = _count('card', 'create')
96
+ card_update = _count('card', 'update')
97
+ card_freeze = _count('card', 'freeze')
98
+ card_fallback = _count('card', 'fallback')
99
+
100
+ token_total = _sum('token', 'usage')
101
+
102
+ err_card = _count('error', 'card_api')
103
+ err_bridge = _count('error', 'bridge_send')
104
+ err_total = err_card + err_bridge
105
+ card_ops = card_create + card_update
106
+ err_rate = f"{err_total / card_ops * 100:.2f}%" if card_ops else "0%"
107
+
108
+ # 构建输出
109
+ if start_date == end_date:
110
+ date_label = start_date
111
+ else:
112
+ date_label = f"{start_date} ~ {end_date}"
113
+
114
+ lines = [
115
+ f"━━━━ Remote Claude 使用统计 ({date_label}) ━━━━",
116
+ "",
117
+ f"会话: 启动 {_fmt_num(sess_start)} | 连接 {_fmt_num(sess_attach + term_connect + lark_attach)} | 结束 {_fmt_num(sess_end)}",
118
+ f"终端: 输入 {_fmt_num(term_input)} 次 | 连接 {_fmt_num(term_connect)} 次",
119
+ f"飞书: 消息 {_fmt_num(lark_msg)} | 选项 {_fmt_num(lark_opt)} | 快捷键 {_fmt_num(lark_key)} | 命令 {_fmt_num(lark_cmd)}",
120
+ f"卡片: 创建 {_fmt_num(card_create)} | 更新 {_fmt_num(card_update)} | 冻结 {_fmt_num(card_freeze)}",
121
+ f"Token: ~{token_total / 1000:.1f}k",
122
+ f"错误: API {_fmt_num(err_card)} ({err_rate})",
123
+ ]
124
+
125
+ if detail:
126
+ lines += [
127
+ "",
128
+ "── 详细分类 ──",
129
+ f"飞书会话: cmd_start {_fmt_num(lark_cmd_start)} | attach {_fmt_num(lark_attach)}",
130
+ f"卡片降级: {_fmt_num(card_fallback)}",
131
+ f"桥接错误: {_fmt_num(err_bridge)}",
132
+ ]
133
+
134
+ conn.close()
135
+ return "\n".join(lines)
136
+
137
+
138
+ def reset_stats() -> str:
139
+ """清空所有统计数据"""
140
+ conn = _get_conn()
141
+ if conn is None:
142
+ return "暂无统计数据"
143
+ try:
144
+ conn.execute("DELETE FROM events")
145
+ conn.execute("DELETE FROM daily_summary")
146
+ conn.execute("DELETE FROM meta WHERE key != 'first_run'")
147
+ conn.commit()
148
+ conn.close()
149
+ return "统计数据已清空"
150
+ except Exception as e:
151
+ return f"清空失败: {e}"
@@ -0,0 +1,165 @@
1
+ """
2
+ Claude CLI 输出组件模型
3
+
4
+ 将 Claude 终端输出解析为结构化组件,供 CardBuilder 渲染为飞书卡片。
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import List, Optional, Union
9
+
10
+
11
+ @dataclass
12
+ class OutputBlock:
13
+ """统一输出块(文本回复、工具调用、Agent/Plan 块的统一表示)
14
+
15
+ parser 不区分三者,统一产出此类型。content 为圆点字符之后的内容(已去掉首列圆点)。
16
+ card_builder 可根据首行内容格式决定渲染方式。
17
+ """
18
+ content: str # 完整块内容(首行去掉圆点,后续行原样,\n 连接)
19
+ is_streaming: bool = False # 首行首列圆点是否 blink
20
+ start_row: int = -1 # 在终端屏幕中的起始行号(用于帧间同一 Block 识别,-1 表示未知)
21
+ ansi_content: str = "" # 对应 content,带 ANSI 转义码
22
+ indicator: str = "" # 首列圆点字符原文(如 ●)
23
+ ansi_indicator: str = "" # 带 ANSI 颜色的圆点字符
24
+
25
+
26
+ @dataclass
27
+ class TextBlock:
28
+ """文本回复(保留作为向后兼容,新代码请使用 OutputBlock)"""
29
+ content: str
30
+ is_streaming: bool = False
31
+
32
+
33
+ @dataclass
34
+ class UserInput:
35
+ """用户输入(❯ 行)"""
36
+ text: str
37
+ ansi_text: str = "" # 对应 text,带 ANSI 转义码
38
+ indicator: str = "" # 首列提示符原文(❯)
39
+ ansi_indicator: str = "" # 带 ANSI 颜色的提示符
40
+
41
+
42
+ @dataclass
43
+ class ToolCall:
44
+ """工具调用块(保留作为向后兼容,新代码请使用 OutputBlock)"""
45
+ tool_name: str
46
+ args_summary: str
47
+ status: str = "running"
48
+ status_detail: str = ""
49
+ output: str = ""
50
+ is_streaming: bool = False
51
+
52
+
53
+ @dataclass
54
+ class AgentBlock:
55
+ """Agent/Plan 块(保留作为向后兼容,新代码请使用 OutputBlock)"""
56
+ agent_type: str
57
+ description: str
58
+ status: str = "running"
59
+ status_detail: str = ""
60
+ stats: str = ""
61
+ sub_calls: List[str] = field(default_factory=list)
62
+ is_streaming: bool = False
63
+
64
+
65
+ @dataclass
66
+ class OptionBlock:
67
+ """选项交互块(统一 option + permission 两种场景)
68
+
69
+ 状态型组件:全局唯一,存储在 ClaudeWindow.option_block,不进入 blocks 累积列表。
70
+ - sub_type="option":AskUserQuestion 选项(2 分割线 input_rows 中检测到编号选项)
71
+ - sub_type="permission":权限确认(1 分割线 bottom_rows 中检测到编号选项)
72
+ """
73
+ sub_type: str = "option" # "option" | "permission"
74
+ tag: str = "" # 分类标签(option 场景)
75
+ title: str = "" # 工具名称(permission 场景)
76
+ content: str = "" # 详细内容(permission 场景)
77
+ question: str = "" # 问题文本
78
+ options: List[dict] = field(default_factory=list) # [{"label": str, "value": str}]
79
+ ansi_raw: str = "" # 整个选项区域的 ANSI 原始文本
80
+ indicator: str = "" # 首列字符原文
81
+ ansi_indicator: str = "" # 带 ANSI 颜色的首列字符
82
+
83
+
84
+ # 向后兼容别名
85
+ PermissionBlock = OptionBlock
86
+
87
+
88
+ @dataclass
89
+ class StatusLine:
90
+ """状态行(✱ 动作... (时间 · tokens))"""
91
+ action: str # 动作动词(如 "Germinating...")
92
+ elapsed: str = "" # 时长(如 "16m 33s")
93
+ tokens: str = "" # token 消耗(如 "↓ 4.3k tokens")
94
+ raw: str = "" # 原始文本
95
+ ansi_raw: str = "" # 对应 raw,带 ANSI 转义码
96
+ indicator: str = "" # 首列星星字符原文(如 ✱)
97
+ ansi_indicator: str = "" # 带 ANSI 颜色的星星字符
98
+
99
+
100
+ @dataclass
101
+ class Divider:
102
+ """水平分割线(区域边界标记,不渲染)
103
+
104
+ Claude CLI 终端有 2 条由 ─ 组成的分割线,将屏幕分为 3 个区域:
105
+ 输出区(上)| ─── | 用户输入框(中)| ─── | 底部栏(下)
106
+ 解析时用于定位区域边界,不作为卡片内容渲染。
107
+ """
108
+ pass
109
+
110
+
111
+ @dataclass
112
+ class BottomBar:
113
+ """底部栏(权限模式、后台任务等状态信息)
114
+
115
+ 固定在终端最底部的状态栏,内容如:
116
+ - ▶▶ bypass permissions on (shift+tab to cycle) · esc to interrupt
117
+ - 2 bashes · ↓ to manage
118
+ - 4 local agents · ↓ to manage · ctrl+f to kill agents
119
+ """
120
+ text: str # 原始文本内容
121
+ ansi_text: str = "" # 对应 text,带 ANSI 转义码
122
+ has_background_agents: bool = False # 底部栏是否包含后台 agent 信息
123
+ agent_count: int = 0 # 后台 agent 数量
124
+ agent_summary: str = "" # agent 摘要文本(如 "4 local agents")
125
+
126
+
127
+ @dataclass
128
+ class AgentPanelBlock:
129
+ """Agent 管理面板(用户按 ↓ 展开的列表或 Enter 查看的详情)
130
+
131
+ 出现在只有 1 条分割线的特殊布局中(输入区消失)。
132
+ 列表模式:显示所有后台 agent 及其状态
133
+ 详情模式:显示单个 agent 的详细信息
134
+ """
135
+ panel_type: str = "list" # "list" | "detail"
136
+ # 列表模式字段
137
+ agent_count: int = 0
138
+ agents: List[dict] = field(default_factory=list) # [{"name": str, "status": str, "is_selected": bool}]
139
+ # 详情模式字段
140
+ agent_name: str = ""
141
+ agent_type: str = ""
142
+ stats: str = ""
143
+ progress: str = ""
144
+ prompt: str = ""
145
+ # 通用字段
146
+ raw_text: str = ""
147
+ ansi_raw: str = ""
148
+
149
+
150
+ @dataclass
151
+ class PlanBlock:
152
+ """计划块(╭│╰ box-drawing 框线包裹的结构化计划内容)
153
+
154
+ 累积型 Block:出现在输出区,保留在 blocks 历史列表中。
155
+ Claude CLI Plan Mode 显示的计划内容。
156
+ """
157
+ title: str # 框内第一行非空文本(如 "Plan to implement")
158
+ content: str # 框内完整内容(去掉边框字符,\\n 连接)
159
+ is_streaming: bool = False # 始终为 False(╭ 不 blink)
160
+ start_row: int = -1
161
+ ansi_content: str = ""
162
+
163
+
164
+ # 所有组件类型的联合类型
165
+ Component = Union[OutputBlock, TextBlock, UserInput, ToolCall, AgentBlock, OptionBlock, PermissionBlock, StatusLine, Divider, BottomBar, AgentPanelBlock, PlanBlock]
@@ -0,0 +1,164 @@
1
+ """
2
+ 通信协议定义
3
+
4
+ 消息格式:JSON + 换行符分隔
5
+ 二进制数据使用 base64 编码
6
+ """
7
+
8
+ import json
9
+ import base64
10
+ from dataclasses import dataclass, asdict
11
+ from typing import Optional, List
12
+ from enum import Enum
13
+
14
+
15
+ class MessageType(str, Enum):
16
+ """消息类型"""
17
+ INPUT = "input" # 客户端 -> 服务端:用户输入
18
+ OUTPUT = "output" # 服务端 -> 客户端:Claude 输出
19
+ HISTORY = "history" # 历史输出(重连时)
20
+ ERROR = "error" # 错误消息
21
+ RESIZE = "resize" # 终端大小变化
22
+
23
+
24
+ @dataclass
25
+ class Message:
26
+ """基础消息"""
27
+ type: str
28
+
29
+ def to_json(self) -> str:
30
+ return json.dumps(asdict(self), ensure_ascii=False)
31
+
32
+ @classmethod
33
+ def from_json(cls, data: str) -> "Message":
34
+ obj = json.loads(data)
35
+ msg_type = obj.get("type")
36
+
37
+ if msg_type == MessageType.INPUT:
38
+ return InputMessage.from_dict(obj)
39
+ elif msg_type == MessageType.OUTPUT:
40
+ return OutputMessage.from_dict(obj)
41
+ elif msg_type == MessageType.HISTORY:
42
+ return HistoryMessage.from_dict(obj)
43
+ elif msg_type == MessageType.ERROR:
44
+ return ErrorMessage.from_dict(obj)
45
+ elif msg_type == MessageType.RESIZE:
46
+ return ResizeMessage.from_dict(obj)
47
+ else:
48
+ raise ValueError(f"Unknown message type: {msg_type}")
49
+
50
+
51
+ @dataclass
52
+ class InputMessage(Message):
53
+ """用户输入消息"""
54
+ data: str # base64 编码的输入
55
+ client_id: str
56
+
57
+ def __init__(self, data: bytes, client_id: str):
58
+ super().__init__(type=MessageType.INPUT)
59
+ self.data = base64.b64encode(data).decode('ascii')
60
+ self.client_id = client_id
61
+
62
+ def get_data(self) -> bytes:
63
+ return base64.b64decode(self.data)
64
+
65
+ @classmethod
66
+ def from_dict(cls, obj: dict) -> "InputMessage":
67
+ msg = object.__new__(cls)
68
+ msg.type = obj["type"]
69
+ msg.data = obj["data"]
70
+ msg.client_id = obj["client_id"]
71
+ return msg
72
+
73
+
74
+ @dataclass
75
+ class OutputMessage(Message):
76
+ """Claude 输出消息"""
77
+ data: str # base64 编码的输出
78
+
79
+ def __init__(self, data: bytes):
80
+ super().__init__(type=MessageType.OUTPUT)
81
+ self.data = base64.b64encode(data).decode('ascii')
82
+
83
+ def get_data(self) -> bytes:
84
+ return base64.b64decode(self.data)
85
+
86
+ @classmethod
87
+ def from_dict(cls, obj: dict) -> "OutputMessage":
88
+ msg = object.__new__(cls)
89
+ msg.type = obj["type"]
90
+ msg.data = obj["data"]
91
+ return msg
92
+
93
+
94
+ @dataclass
95
+ class HistoryMessage(Message):
96
+ """历史输出消息(重连时发送)"""
97
+ data: str # base64 编码的历史输出
98
+
99
+ def __init__(self, data: bytes):
100
+ super().__init__(type=MessageType.HISTORY)
101
+ self.data = base64.b64encode(data).decode('ascii')
102
+
103
+ def get_data(self) -> bytes:
104
+ return base64.b64decode(self.data)
105
+
106
+ @classmethod
107
+ def from_dict(cls, obj: dict) -> "HistoryMessage":
108
+ msg = object.__new__(cls)
109
+ msg.type = obj["type"]
110
+ msg.data = obj["data"]
111
+ return msg
112
+
113
+
114
+ @dataclass
115
+ class ErrorMessage(Message):
116
+ """错误消息"""
117
+ message: str
118
+ code: Optional[str] = None
119
+
120
+ def __init__(self, message: str, code: Optional[str] = None):
121
+ super().__init__(type=MessageType.ERROR)
122
+ self.message = message
123
+ self.code = code
124
+
125
+ @classmethod
126
+ def from_dict(cls, obj: dict) -> "ErrorMessage":
127
+ msg = object.__new__(cls)
128
+ msg.type = obj["type"]
129
+ msg.message = obj["message"]
130
+ msg.code = obj.get("code")
131
+ return msg
132
+
133
+
134
+ @dataclass
135
+ class ResizeMessage(Message):
136
+ """终端大小变化消息"""
137
+ rows: int
138
+ cols: int
139
+ client_id: str
140
+
141
+ def __init__(self, rows: int, cols: int, client_id: str):
142
+ super().__init__(type=MessageType.RESIZE)
143
+ self.rows = rows
144
+ self.cols = cols
145
+ self.client_id = client_id
146
+
147
+ @classmethod
148
+ def from_dict(cls, obj: dict) -> "ResizeMessage":
149
+ msg = object.__new__(cls)
150
+ msg.type = obj["type"]
151
+ msg.rows = obj["rows"]
152
+ msg.cols = obj["cols"]
153
+ msg.client_id = obj["client_id"]
154
+ return msg
155
+
156
+
157
+ def encode_message(msg: Message) -> bytes:
158
+ """编码消息为字节流(JSON + 换行符)"""
159
+ return (msg.to_json() + "\n").encode('utf-8')
160
+
161
+
162
+ def decode_message(data: bytes) -> Message:
163
+ """解码消息"""
164
+ return Message.from_json(data.decode('utf-8').strip())