ring-cli 0.2.0__py3-none-any.whl

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.
ring/hook_protocol.py ADDED
@@ -0,0 +1,264 @@
1
+ """Provider-neutral hook event normalization for RiNG.
2
+
3
+ 外部工具只要能把事件 JSON 餵給 ``ring hook``,就能寫入同一份 registry。
4
+ provider adapter 負責把各工具的事件名稱與欄位正規化,registry writer 不需要知道
5
+ Claude Code、Codex 或未來工具的細節。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from typing import Any, Protocol
13
+
14
+ from ring.registry import Status
15
+
16
+
17
+ class HookAdapter(Protocol):
18
+ """把某個 provider 的 hook payload 正規化成 RiNG event。"""
19
+
20
+ provider: str
21
+ process_names: tuple[str, ...]
22
+
23
+ def normalize(self, data: Mapping[str, Any]) -> NormalizedHookEvent | None:
24
+ """回傳正規化事件;不支援或 payload 不足時回 ``None``。"""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class NormalizedHookEvent:
29
+ provider: str
30
+ event: str
31
+ session_id: str
32
+ cwd: str
33
+ status: Status
34
+ transcript_path: str = ""
35
+ tty: str = ""
36
+ last_action: str = ""
37
+ waiting_for: str = ""
38
+
39
+
40
+ _ALWAYS_STATUS = {
41
+ "SessionStart": Status.WORKING,
42
+ "UserPromptSubmit": Status.WORKING,
43
+ "Stop": Status.IDLE,
44
+ "SessionEnd": Status.ENDED,
45
+ "PermissionRequest": Status.WAITING,
46
+ }
47
+
48
+ _ACTION_REQUIRED_NOTIFICATION_TYPES = {
49
+ "permission_prompt",
50
+ "elicitation_dialog",
51
+ }
52
+
53
+ _ACTION_REQUIRED_WAITING_FOR = {
54
+ "approval",
55
+ "choice",
56
+ "choices",
57
+ "elicitation",
58
+ "input",
59
+ "option",
60
+ "options",
61
+ "permission",
62
+ "question",
63
+ "questions",
64
+ "selection",
65
+ "user",
66
+ "user_input",
67
+ }
68
+
69
+ _IDLE_WAITING_FOR = {
70
+ "",
71
+ "complete",
72
+ "done",
73
+ "idle",
74
+ "instruction",
75
+ "next_step",
76
+ "none",
77
+ "prompt",
78
+ "turn_complete",
79
+ "user_prompt",
80
+ }
81
+
82
+
83
+ class CommonHookAdapter:
84
+ """共用事件語意 adapter。
85
+
86
+ 適合 Claude Code、Codex,以及未來沿用類似 lifecycle event 的 agent CLI。
87
+ """
88
+
89
+ def __init__(self, provider: str, process_names: tuple[str, ...]) -> None:
90
+ self.provider = provider
91
+ self.process_names = process_names
92
+
93
+ def normalize(self, data: Mapping[str, Any]) -> NormalizedHookEvent | None:
94
+ event = _first_str(data, "event", "event_name", "hook_event_name", "hookEventName")
95
+ sid = _first_str(data, "session_id", "sessionId", "thread_id", "threadId", "id")
96
+ if not event or not sid:
97
+ return None
98
+ status = _ALWAYS_STATUS.get(event)
99
+ explicit_requires_action = _explicit_requires_action(data)
100
+ if event == "SessionEnd":
101
+ status = Status.ENDED
102
+ elif event in {"SessionStart", "UserPromptSubmit"}:
103
+ status = Status.WORKING
104
+ elif explicit_requires_action is not None:
105
+ status = Status.WAITING if explicit_requires_action else Status.IDLE
106
+ elif event == "Notification":
107
+ status = Status.WAITING if _is_action_required_notification(data) else Status.IDLE
108
+ elif event == "PreToolUse":
109
+ # 工具要動了:權限 / 選項類 → 🔴 WAITING;其餘 → 🟢 WORKING。
110
+ # 非 action 的 PreToolUse 也明確收斂成 WORKING(而非不寫),這樣
111
+ # 上一個 WAITING(例如剛答完的權限)會被下一個工具動作清掉。
112
+ status = Status.WAITING if _is_action_required_payload(data) else Status.WORKING
113
+ elif event == "PostToolUse":
114
+ # 工具跑完代表使用者已放行、agent 又在動 → 🟢 WORKING。
115
+ # 這是「回應完」最乾淨的訊號,用來清掉卡住的 WAITING、止住重複通知。
116
+ status = Status.WORKING
117
+ if status is None:
118
+ return None
119
+ return NormalizedHookEvent(
120
+ provider=self.provider,
121
+ event=event,
122
+ session_id=_qualified_session_id(self.provider, sid),
123
+ cwd=_first_str(data, "cwd", "current_working_directory", "currentWorkingDirectory"),
124
+ status=status,
125
+ transcript_path=_first_str(data, "transcript_path", "transcriptPath", "rollout_path", "rolloutPath"),
126
+ tty=_first_str(data, "tty"),
127
+ last_action=_first_str(data, "last_action", "lastAction", "message", "title"),
128
+ waiting_for=_waiting_for(data),
129
+ )
130
+
131
+
132
+ def adapter_for(provider: str) -> HookAdapter:
133
+ normalized = normalize_provider(provider)
134
+ if normalized in {"claude", "claude-code"}:
135
+ return CommonHookAdapter("claude-code", ("claude",))
136
+ if normalized == "codex":
137
+ return CommonHookAdapter("codex", ("codex",))
138
+ return CommonHookAdapter(normalized or "generic", ())
139
+
140
+
141
+ def provider_from_payload(data: Mapping[str, Any], fallback: str = "claude-code") -> str:
142
+ # 只認明確的 "provider" 欄位。不要拿 "source"——Claude Code 的 SessionStart payload
143
+ # 帶 source="startup"/"resume"/"clear"/"compact"(觸發來源,不是 provider),
144
+ # 誤當 provider 會生出 "startup:<id>" 幽靈 session、且永遠不會被標離場。
145
+ return normalize_provider(_first_str(data, "provider") or fallback)
146
+
147
+
148
+ def normalize_provider(provider: str) -> str:
149
+ return provider.strip().lower().replace("_", "-")
150
+
151
+
152
+ def _first_str(data: Mapping[str, Any], *keys: str) -> str:
153
+ for key in keys:
154
+ value = data.get(key)
155
+ if isinstance(value, str) and value:
156
+ return value
157
+ return ""
158
+
159
+
160
+ def _is_action_required_notification(data: Mapping[str, Any]) -> bool:
161
+ notification_type = _normalize_token(
162
+ _first_str(data, "notification_type", "notificationType", "raw_notification_type", "rawNotificationType")
163
+ )
164
+ if notification_type in _ACTION_REQUIRED_NOTIFICATION_TYPES:
165
+ return True
166
+ return _is_action_required_payload(data)
167
+
168
+
169
+ def _explicit_requires_action(data: Mapping[str, Any]) -> bool | None:
170
+ for key in (
171
+ "requires_action",
172
+ "requiresAction",
173
+ "action_required",
174
+ "actionRequired",
175
+ "needs_user_action",
176
+ "needsUserAction",
177
+ "requires_input",
178
+ "requiresInput",
179
+ "interactive",
180
+ ):
181
+ parsed = _parse_bool(data.get(key))
182
+ if parsed is not None:
183
+ return parsed
184
+
185
+ waiting_for = _waiting_for(data)
186
+ if not waiting_for:
187
+ return None
188
+ if waiting_for in _ACTION_REQUIRED_WAITING_FOR:
189
+ return True
190
+ if waiting_for in _IDLE_WAITING_FOR:
191
+ return False
192
+ return None
193
+
194
+
195
+ def _is_action_required_payload(data: Mapping[str, Any]) -> bool:
196
+ tool_name = _first_str(data, "tool_name", "toolName", "tool")
197
+ if tool_name == "AskUserQuestion":
198
+ return True
199
+
200
+ tool_input = data.get("tool_input") or data.get("toolInput") or data.get("input")
201
+ if isinstance(tool_input, Mapping):
202
+ questions = tool_input.get("questions")
203
+ if isinstance(questions, list) and questions:
204
+ return True
205
+ options = tool_input.get("options")
206
+ if isinstance(options, list) and options:
207
+ return True
208
+
209
+ for key in ("questions", "options", "choices"):
210
+ value = data.get(key)
211
+ if isinstance(value, list) and value:
212
+ return True
213
+ return False
214
+
215
+
216
+ def _waiting_for(data: Mapping[str, Any]) -> str:
217
+ return _normalize_token(
218
+ _first_str(
219
+ data,
220
+ "waiting_for",
221
+ "waitingFor",
222
+ "requires",
223
+ "reason",
224
+ "interaction",
225
+ "interaction_type",
226
+ "interactionType",
227
+ )
228
+ ).replace("-", "_")
229
+
230
+
231
+ def _parse_bool(value: object) -> bool | None:
232
+ if isinstance(value, bool):
233
+ return value
234
+ if isinstance(value, str):
235
+ normalized = value.strip().lower()
236
+ if normalized in {"1", "true", "yes", "y", "on"}:
237
+ return True
238
+ if normalized in {"0", "false", "no", "n", "off"}:
239
+ return False
240
+ return None
241
+
242
+
243
+ def _normalize_token(value: str) -> str:
244
+ return value.strip().lower()
245
+
246
+
247
+ def _qualified_session_id(provider: str, sid: str) -> str:
248
+ if ":" in sid:
249
+ return sid
250
+ if provider in {"claude", "claude-code"}:
251
+ return sid
252
+ return f"{provider}:{sid}"
253
+
254
+
255
+ HOOK_EVENTS = (
256
+ "SessionStart",
257
+ "UserPromptSubmit",
258
+ "Notification",
259
+ "PermissionRequest",
260
+ "PreToolUse",
261
+ "PostToolUse",
262
+ "Stop",
263
+ "SessionEnd",
264
+ )
ring/i18n.py ADDED
@@ -0,0 +1,52 @@
1
+ """RiNG i18n — gettext。
2
+
3
+ msgid 用**台灣漢語**:原始碼即預設語言,所以 zh-Hant 不需要任何 ``.mo``。
4
+ 其他語言放 ``locale/<lang>/LC_MESSAGES/ring.mo``(由 ``.po`` 編譯,見 README / ``poe i18n-compile``)。
5
+
6
+ 切語言:process 啟動時 ``set_lang()`` 裝上對應 translation;之後 ``_()`` / ``ngettext()`` 就用它。
7
+ 在 ``import ring.tui`` 之前先 set_lang,class-level 的 Footer 按鍵說明也會跟著走。
8
+
9
+ ``_()`` 與 ``ngettext()`` 都吃 kwargs:``_("…{x}…", x=1)`` 等同 gettext 後再 ``.format(x=1)``。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import gettext as _gettext
15
+ import os
16
+ from pathlib import Path
17
+
18
+ _LOCALE_DIR = Path(__file__).parent / "locale"
19
+ _DOMAIN = "ring"
20
+
21
+ _current: _gettext.NullTranslations = _gettext.NullTranslations()
22
+
23
+
24
+ def resolve_lang(explicit: str | None = None) -> str:
25
+ raw = (explicit or os.environ.get("RING_LANG") or os.environ.get("LANG") or "").lower()
26
+ return "en" if raw.startswith("en") else "zh-Hant"
27
+
28
+
29
+ def set_lang(lang: str | None) -> None:
30
+ """裝上對應語言的 translation。zh-Hant=msgid 本身(不需 .mo)。"""
31
+ global _current
32
+ if resolve_lang(lang) == "zh-Hant":
33
+ _current = _gettext.NullTranslations()
34
+ return
35
+ try:
36
+ _current = _gettext.translation(_DOMAIN, _LOCALE_DIR, languages=[resolve_lang(lang)])
37
+ except OSError: # .mo 不在 → 安靜退回 msgid(中文)
38
+ _current = _gettext.NullTranslations()
39
+
40
+
41
+ def gettext(msgid: str, /, **kw: object) -> str:
42
+ # msgid 設 positional-only,這樣 format kwarg 取任何名字(msg / n …)都不會撞參數。
43
+ out = _current.gettext(msgid)
44
+ return out.format(**kw) if kw else out
45
+
46
+
47
+ def ngettext(singular: str, plural: str, count: int, /, **kw: object) -> str:
48
+ out = _current.ngettext(singular, plural, count)
49
+ return out.format(**kw) if kw else out
50
+
51
+
52
+ _ = gettext
ring/ipc.py ADDED
@@ -0,0 +1,220 @@
1
+ """檔案式 IPC 輔助——RiNG TUI 與 ``ring focus`` subprocess 之間的溝通橋梁。
2
+
3
+ 機制概述
4
+ ---------
5
+ - ``~/.config/ring/focus-request``:由 ``ring focus <id>`` 寫入、TUI poll 後讀取並刪除(消費即焚)。
6
+ - ``~/.config/ring/tui-presence``:由 RiNG TUI 啟動時寫入、結束時刪除,同時做「TUI 是否在跑」的判斷依據。
7
+
8
+ 設計原則
9
+ ---------
10
+ - 純 stdlib,零新依賴。
11
+ - 所有跨 process 檔案操作包 try/except,失敗安靜吞掉,不打斷 TUI / 通知主流程。
12
+ - ``_config_dir``、``_focus_request_path``、``_presence_path`` 為可在測試中注入的私有常數,
13
+ 使用者透過 ``tmp_path`` 覆寫即可隔離測試環境。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+ from typing import TypedDict
24
+
25
+ # --------------------------------------------------------------------------- 路徑常數(可在測試中覆寫)
26
+
27
+ _CONFIG_DIR: Path = Path.home() / ".config" / "ring"
28
+ _FOCUS_REQUEST_PATH: Path = _CONFIG_DIR / "focus-request"
29
+ _PRESENCE_PATH: Path = _CONFIG_DIR / "tui-presence"
30
+
31
+ # focus-request 的有效時效(秒):超過視為 stale,忽略。
32
+ _REQUEST_TTL: float = 30.0
33
+
34
+ # presence 的有效時效(秒):超過視為 stale,即使 pid 看起來還活著也不信任。
35
+ _PRESENCE_TTL: float = 300.0
36
+
37
+
38
+ # --------------------------------------------------------------------------- 型別
39
+
40
+
41
+ class _FocusRequest(TypedDict):
42
+ session_id: str
43
+ ts: float
44
+
45
+
46
+ class _TuiPresence(TypedDict):
47
+ tty: str
48
+ pid: int
49
+ ts: float
50
+
51
+
52
+ # --------------------------------------------------------------------------- focus-request 相關
53
+
54
+
55
+ def write_focus_request(session_id: str, *, request_path: Path | None = None) -> None:
56
+ """把 focus-request 寫入磁碟,供 TUI poll 讀取。
57
+
58
+ :param session_id: 要聚焦的 session ID。
59
+ :param request_path: 可注入路徑(測試用);省略時用預設路徑。
60
+ """
61
+ path = request_path or _FOCUS_REQUEST_PATH
62
+ payload: _FocusRequest = {"session_id": session_id, "ts": time.time()}
63
+ try:
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ path.write_text(json.dumps(payload), encoding="utf-8")
66
+ except Exception:
67
+ pass
68
+
69
+
70
+ def read_focus_request(
71
+ *,
72
+ request_path: Path | None = None,
73
+ ttl: float = _REQUEST_TTL,
74
+ ) -> str | None:
75
+ """讀取 focus-request 並消費即焚。
76
+
77
+ - 無檔案 → 回傳 ``None``,不刪(無檔可刪)。
78
+ - 無效 JSON → 刪掉爛檔,回傳 ``None``。
79
+ - 過期(超過 ttl 秒)→ 刪檔,回傳 ``None``。
80
+ - 有效 request → 刪檔,回傳 ``session_id``。
81
+
82
+ :param request_path: 可注入路徑(測試用)。
83
+ :param ttl: 有效時效秒數,超過視為 stale。
84
+ :returns: session_id 或 None。
85
+ """
86
+ path = request_path or _FOCUS_REQUEST_PATH
87
+ try:
88
+ raw = path.read_text(encoding="utf-8")
89
+ except Exception:
90
+ return None
91
+
92
+ try:
93
+ data: _FocusRequest = json.loads(raw)
94
+ session_id = str(data["session_id"])
95
+ ts = float(data["ts"])
96
+ except Exception:
97
+ # 解析失敗:刪掉爛檔
98
+ try:
99
+ path.unlink()
100
+ except Exception:
101
+ pass
102
+ return None
103
+
104
+ age = time.time() - ts
105
+ # 不論有效或過期,都刪掉(消費即焚;過期的也不留)
106
+ try:
107
+ path.unlink()
108
+ except Exception:
109
+ pass
110
+
111
+ if age > ttl:
112
+ return None
113
+
114
+ return session_id
115
+
116
+
117
+ # --------------------------------------------------------------------------- tui-presence 相關
118
+
119
+
120
+ def _pid_alive(pid: int) -> bool:
121
+ """檢查 pid 是否仍存活(送 signal 0)。"""
122
+ try:
123
+ os.kill(pid, 0)
124
+ return True
125
+ except (ProcessLookupError, PermissionError):
126
+ return False
127
+ except Exception:
128
+ return False
129
+
130
+
131
+ def write_tui_presence(*, presence_path: Path | None = None) -> None:
132
+ """將目前 process 的 tty / pid / ts 寫入 presence 檔。
133
+
134
+ tty 取自 controlling terminal(``os.ttyname(sys.stdout.fileno())`` 或類似)。
135
+ 若無法取得 tty(非互動終端)則寫空字串,讓 activate 步驟跳過而不崩。
136
+
137
+ :param presence_path: 可注入路徑(測試用)。
138
+ """
139
+ path = presence_path or _PRESENCE_PATH
140
+ tty = ""
141
+ try:
142
+ # 優先用 stdout;若 stdout 非 tty,嘗試直接開 /dev/tty
143
+ if sys.stdout.isatty():
144
+ tty = os.ttyname(sys.stdout.fileno())
145
+ else:
146
+ fd = os.open("/dev/tty", os.O_RDONLY | os.O_NOCTTY)
147
+ try:
148
+ tty = os.ttyname(fd)
149
+ finally:
150
+ os.close(fd)
151
+ except Exception:
152
+ pass # headless / no controlling terminal,tty 留空字串
153
+
154
+ payload: _TuiPresence = {"tty": tty, "pid": os.getpid(), "ts": time.time()}
155
+ try:
156
+ path.parent.mkdir(parents=True, exist_ok=True)
157
+ path.write_text(json.dumps(payload), encoding="utf-8")
158
+ except Exception:
159
+ pass
160
+
161
+
162
+ def read_tui_presence(
163
+ *,
164
+ presence_path: Path | None = None,
165
+ ttl: float = _PRESENCE_TTL,
166
+ ) -> _TuiPresence | None:
167
+ """讀取 presence 檔,判斷 TUI 是否確實在跑。
168
+
169
+ 判定規則:
170
+ - 無檔案、無效 JSON → ``None``。
171
+ - ts 超過 ttl → stale,刪檔並回傳 ``None``。
172
+ - pid 已死 → stale,刪檔並回傳 ``None``。
173
+ - 以上均通過 → 回傳 presence dict。
174
+
175
+ :param presence_path: 可注入路徑(測試用)。
176
+ :param ttl: 有效時效秒數。
177
+ :returns: ``_TuiPresence`` 或 ``None``。
178
+ """
179
+ path = presence_path or _PRESENCE_PATH
180
+ try:
181
+ raw = path.read_text(encoding="utf-8")
182
+ except Exception:
183
+ return None
184
+
185
+ try:
186
+ data: _TuiPresence = json.loads(raw)
187
+ tty = str(data.get("tty", ""))
188
+ pid = int(data["pid"])
189
+ ts = float(data["ts"])
190
+ except Exception:
191
+ return None
192
+
193
+ age = time.time() - ts
194
+ if age > ttl:
195
+ try:
196
+ path.unlink()
197
+ except Exception:
198
+ pass
199
+ return None
200
+
201
+ if not _pid_alive(pid):
202
+ try:
203
+ path.unlink()
204
+ except Exception:
205
+ pass
206
+ return None
207
+
208
+ return _TuiPresence(tty=tty, pid=pid, ts=ts)
209
+
210
+
211
+ def clear_tui_presence(*, presence_path: Path | None = None) -> None:
212
+ """刪除 presence 檔(TUI 離場時呼叫)。
213
+
214
+ :param presence_path: 可注入路徑(測試用)。
215
+ """
216
+ path = presence_path or _PRESENCE_PATH
217
+ try:
218
+ path.unlink()
219
+ except Exception:
220
+ pass
ring/labels.py ADDED
@@ -0,0 +1,50 @@
1
+ """Session 自訂標籤——本機持久化「這個 session 是什麼」。
2
+
3
+ 存在 ``~/.config/ring/labels.json``:``{session_id: label}``。純 stdlib,所有檔案操作
4
+ 失敗一律安靜吞掉(標籤是錦上添花,不該打斷 TUI / 快照主流程)。``_LABELS_PATH`` 可在
5
+ 測試中以 ``path=`` 參數覆寫隔離。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ _LABELS_PATH: Path = Path.home() / ".config" / "ring" / "labels.json"
14
+
15
+
16
+ def load_labels(*, path: Path | None = None) -> dict[str, str]:
17
+ """讀整份標籤表;缺檔 / 壞檔 / 型別不符一律回空 dict。"""
18
+ p = path or _LABELS_PATH
19
+ try:
20
+ data = json.loads(p.read_text(encoding="utf-8"))
21
+ except Exception:
22
+ return {}
23
+ if not isinstance(data, dict):
24
+ return {}
25
+ return {str(k): v for k, v in data.items() if isinstance(v, str) and v}
26
+
27
+
28
+ def get_label(session_id: str, *, path: Path | None = None) -> str:
29
+ """取單一 session 的標籤;沒有就回空字串。"""
30
+ return load_labels(path=path).get(session_id, "")
31
+
32
+
33
+ def set_label(session_id: str, label: str, *, path: Path | None = None) -> None:
34
+ """設定 / 更新某 session 的標籤;傳空字串(或全空白)則移除該標籤。"""
35
+ p = path or _LABELS_PATH
36
+ labels = load_labels(path=path)
37
+ label = label.strip()
38
+ if label:
39
+ labels[session_id] = label
40
+ elif session_id not in labels:
41
+ return # 本來就沒有,且要清空 → 不必寫檔
42
+ else:
43
+ labels.pop(session_id, None)
44
+ try:
45
+ p.parent.mkdir(parents=True, exist_ok=True)
46
+ tmp = p.with_suffix(".json.tmp")
47
+ tmp.write_text(json.dumps(labels, ensure_ascii=False), encoding="utf-8")
48
+ tmp.replace(p) # atomic
49
+ except Exception:
50
+ pass
Binary file