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/__init__.py +12 -0
- ring/__main__.py +6 -0
- ring/cli.py +685 -0
- ring/config.py +276 -0
- ring/focus/__init__.py +65 -0
- ring/focus/applescript.py +34 -0
- ring/focus/base.py +19 -0
- ring/focus/iterm2.py +28 -0
- ring/focus/terminal.py +25 -0
- ring/focus/tmux.py +38 -0
- ring/hook.py +482 -0
- ring/hook_protocol.py +264 -0
- ring/i18n.py +52 -0
- ring/ipc.py +220 -0
- ring/labels.py +50 -0
- ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- ring/locale/en/LC_MESSAGES/ring.po +395 -0
- ring/locale/ring.pot +484 -0
- ring/notify/__init__.py +120 -0
- ring/notify/base.py +37 -0
- ring/notify/command.py +18 -0
- ring/notify/notify_send.py +30 -0
- ring/notify/osascript_notifier.py +32 -0
- ring/notify/terminal_notifier.py +64 -0
- ring/osascript.py +17 -0
- ring/registry.py +851 -0
- ring/sources/__init__.py +94 -0
- ring/sources/base.py +17 -0
- ring/sources/claude_code.py +23 -0
- ring/sources/codex.py +16 -0
- ring/sources/hook_registry.py +16 -0
- ring/tui.py +360 -0
- ring/watcher.py +107 -0
- ring_cli-0.2.0.dist-info/METADATA +334 -0
- ring_cli-0.2.0.dist-info/RECORD +38 -0
- ring_cli-0.2.0.dist-info/WHEEL +4 -0
- ring_cli-0.2.0.dist-info/entry_points.txt +3 -0
- ring_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
ring/hook.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""RiNG 的 provider-neutral hook handler——精準狀態的來源。
|
|
2
|
+
|
|
3
|
+
Agent CLI 在各事件把一段 JSON 從 stdin 餵進來。我們據此 upsert 一份
|
|
4
|
+
``~/.config/ring/sessions/<session_id>.json``,讓 registry 讀到精準狀態
|
|
5
|
+
(zero-config 靠 mtime / state 猜不出「在等你」,這裡靠事件直接知道)。
|
|
6
|
+
|
|
7
|
+
設計原則:hook 永遠 exit 0、不擋住 session;解析失敗就安靜放行。
|
|
8
|
+
|
|
9
|
+
事件 → 狀態:
|
|
10
|
+
SessionStart / UserPromptSubmit → 🟢 工作中(剛開始 / 你剛回話,台上在跑)
|
|
11
|
+
PreToolUse(非 action)/ PostToolUse → 🟢 工作中(工具在動,順便清掉剛答完的等你)
|
|
12
|
+
Stop → 🟡 跑完停著(回完一輪,不代表需要你回應)
|
|
13
|
+
PermissionRequest / actionable Notification / AskUserQuestion → 🔴 等你(需要你決策)
|
|
14
|
+
SessionEnd → 刪檔(乾淨離場)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
from urllib.parse import quote
|
|
29
|
+
|
|
30
|
+
from ring.config import get_config
|
|
31
|
+
from ring.hook_protocol import HOOK_EVENTS, adapter_for, provider_from_payload
|
|
32
|
+
from ring.i18n import gettext as _
|
|
33
|
+
from ring.i18n import set_lang
|
|
34
|
+
from ring.registry import (
|
|
35
|
+
RING_REGISTRY,
|
|
36
|
+
Status,
|
|
37
|
+
_extract_todo,
|
|
38
|
+
_latest_action,
|
|
39
|
+
_pid_tty,
|
|
40
|
+
_tail_records,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_HOOK_EVENTS = list(HOOK_EVENTS)
|
|
44
|
+
|
|
45
|
+
# Codex 的 hooks.json 用跟 Claude 同樣的 PascalCase 事件名,但只支援其中一小撮。
|
|
46
|
+
# 保守取有實證可用的:PermissionRequest(→ 🔴 等核可)、PreToolUse(→ 動作/清除)、
|
|
47
|
+
# Stop(→ 🟡 回合結束、清掉 waiting)。多裝 Codex 不認的事件有風險,故不照搬 Claude 全套。
|
|
48
|
+
_CODEX_HOOK_EVENTS = ["PreToolUse", "PermissionRequest", "Stop"]
|
|
49
|
+
|
|
50
|
+
# hook command 的 timeout(秒)。給足,因為 notify_backend="agent-hooks" 時權限 modal 會
|
|
51
|
+
# block 到使用者作答。install 用它判斷「既有條目要不要更新」——舊版裝的 timeout=10 會被升上來。
|
|
52
|
+
_HOOK_TIMEOUT = 600
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class _HookTarget:
|
|
57
|
+
"""一個 hook 安裝目標:settings 檔、要裝的事件、command、重啟提示。"""
|
|
58
|
+
|
|
59
|
+
path: Path
|
|
60
|
+
events: list[str]
|
|
61
|
+
command: str
|
|
62
|
+
restart_hint: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _hook_targets() -> list[_HookTarget]:
|
|
66
|
+
"""目前適用的安裝目標。Claude 一律裝;Codex 只在 ``~/.codex`` 存在(有在用)時才裝。"""
|
|
67
|
+
home = Path.home()
|
|
68
|
+
targets = [
|
|
69
|
+
_HookTarget(
|
|
70
|
+
home / ".claude" / "settings.json",
|
|
71
|
+
_HOOK_EVENTS,
|
|
72
|
+
"ring hook",
|
|
73
|
+
_("重開 Claude Code session 後,🔴 等你狀態就會精準起來。"),
|
|
74
|
+
)
|
|
75
|
+
]
|
|
76
|
+
if (home / ".codex").is_dir():
|
|
77
|
+
targets.append(
|
|
78
|
+
_HookTarget(
|
|
79
|
+
home / ".codex" / "hooks.json",
|
|
80
|
+
_CODEX_HOOK_EVENTS,
|
|
81
|
+
"ring hook --provider codex",
|
|
82
|
+
_(
|
|
83
|
+
"重開 Codex session、並在它詢問時「信任」這個 hook,🔴 等你狀態才會精準起來"
|
|
84
|
+
"(Codex 不會執行未信任的 hook)。"
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return targets
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ps_row(pid: int) -> tuple[int, str] | None:
|
|
92
|
+
"""回傳給定 pid 的 (ppid, comm);失敗回 None。"""
|
|
93
|
+
try:
|
|
94
|
+
out = subprocess.run(
|
|
95
|
+
["ps", "-o", "ppid=,comm=", "-p", str(pid)], capture_output=True, text=True, timeout=2
|
|
96
|
+
).stdout.strip()
|
|
97
|
+
except (OSError, subprocess.SubprocessError):
|
|
98
|
+
return None
|
|
99
|
+
parts = out.split(None, 1)
|
|
100
|
+
if len(parts) < 2:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
return int(parts[0]), parts[1]
|
|
104
|
+
except ValueError:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _session_tty(process_names: tuple[str, ...]) -> str:
|
|
109
|
+
"""hook 是 agent CLI 的後代——往上找到 provider process,回它的控制終端 tty。
|
|
110
|
+
|
|
111
|
+
這是「session → 哪個終端」最精準的對應(不必靠 cwd 猜),給 iTerm2 跳轉用。
|
|
112
|
+
"""
|
|
113
|
+
if not process_names:
|
|
114
|
+
return ""
|
|
115
|
+
pid = os.getppid()
|
|
116
|
+
for _attempt in range(12):
|
|
117
|
+
row = _ps_row(pid)
|
|
118
|
+
if row is None:
|
|
119
|
+
return ""
|
|
120
|
+
ppid, comm = row
|
|
121
|
+
if os.path.basename(comm.strip()) in process_names:
|
|
122
|
+
return _pid_tty(pid)
|
|
123
|
+
if ppid <= 1:
|
|
124
|
+
return ""
|
|
125
|
+
pid = ppid
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_hook(provider: str = "claude-code") -> int:
|
|
130
|
+
"""讀一次 stdin → 寫 RiNG registry 狀態(看板)→(可選)把決策委派給 agent-hooks。
|
|
131
|
+
|
|
132
|
+
委派只在 ``notify_backend == "agent-hooks"`` 且 PATH 上有 ``agent-hooks`` 時發生:
|
|
133
|
+
把原始 payload 透傳給 ``agent-hooks callback``,由它同步出 modal、收按鈕、把決策
|
|
134
|
+
寫到 stdout 回給 Claude。其餘情況 RiNG 只記狀態、exit 0(你在終端自己回答)。
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
raw = sys.stdin.read()
|
|
138
|
+
except OSError:
|
|
139
|
+
return 0
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(raw)
|
|
142
|
+
except (json.JSONDecodeError, ValueError):
|
|
143
|
+
return 0
|
|
144
|
+
if not isinstance(data, dict):
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
selected_provider = provider_from_payload(data, fallback=provider)
|
|
148
|
+
_record_session_state(data, selected_provider)
|
|
149
|
+
return _delegate_to_agent_hooks(raw, selected_provider)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
|
|
153
|
+
"""把事件正規化後 upsert / 刪除 RiNG registry 檔(看板狀態)。失敗安靜吞,不回傳。"""
|
|
154
|
+
adapter = adapter_for(selected_provider)
|
|
155
|
+
event = adapter.normalize(data)
|
|
156
|
+
if event is None:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
path = RING_REGISTRY / f"{quote(event.session_id, safe=':')}.json"
|
|
160
|
+
if event.status is Status.ENDED:
|
|
161
|
+
path.unlink(missing_ok=True) # 乾淨離場:直接消失
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
last_action, todo = event.last_action or "—", None
|
|
165
|
+
tp = event.transcript_path
|
|
166
|
+
if tp:
|
|
167
|
+
records = _tail_records(Path(tp))
|
|
168
|
+
if records:
|
|
169
|
+
last_action = _latest_action(records)
|
|
170
|
+
todo = _extract_todo(records)
|
|
171
|
+
|
|
172
|
+
payload: dict[str, Any] = {
|
|
173
|
+
"session_id": event.session_id,
|
|
174
|
+
"provider": event.provider,
|
|
175
|
+
"cwd": event.cwd,
|
|
176
|
+
"origin_cwd": event.cwd,
|
|
177
|
+
"status": event.status.value,
|
|
178
|
+
"last_active": time.time(),
|
|
179
|
+
"last_action": last_action,
|
|
180
|
+
}
|
|
181
|
+
if todo:
|
|
182
|
+
payload["todo"] = list(todo)
|
|
183
|
+
if event.waiting_for:
|
|
184
|
+
payload["waiting_for"] = event.waiting_for
|
|
185
|
+
tty = event.tty or _session_tty(adapter.process_names)
|
|
186
|
+
if tty:
|
|
187
|
+
payload["tty"] = tty
|
|
188
|
+
|
|
189
|
+
RING_REGISTRY.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
tmp = path.with_suffix(".json.tmp")
|
|
191
|
+
tmp.write_text(json.dumps(payload))
|
|
192
|
+
tmp.replace(path) # atomic
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _delegate_to_agent_hooks(raw: str, selected_provider: str) -> int:
|
|
196
|
+
"""notify_backend=="agent-hooks" 且 agent-hooks 在 PATH → 透傳 payload 給它出決策。
|
|
197
|
+
|
|
198
|
+
把原始 stdin 餵給 ``agent-hooks callback``,stdout 直接繼承(agent-hooks 把 hook
|
|
199
|
+
response 寫給 Claude)。回傳 agent-hooks 的 exit code。沒設 / 沒裝 / 失敗 → 回 0,
|
|
200
|
+
不影響 session。不設內部 timeout:權限對話框會 block 到使用者作答(外層由 Claude
|
|
201
|
+
的 hook timeout 控)。
|
|
202
|
+
"""
|
|
203
|
+
if get_config().notify_backend != "agent-hooks":
|
|
204
|
+
return 0
|
|
205
|
+
if shutil.which("agent-hooks") is None:
|
|
206
|
+
return 0
|
|
207
|
+
cmd = ["agent-hooks", "callback"]
|
|
208
|
+
if selected_provider in {"claude-code", "codex"}:
|
|
209
|
+
cmd += ["--provider", selected_provider]
|
|
210
|
+
try:
|
|
211
|
+
return subprocess.run(cmd, input=raw, text=True).returncode
|
|
212
|
+
except Exception:
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class HookStatus:
|
|
218
|
+
"""某個 hook provider 的安裝狀態快照(唯讀,不拋例外)。"""
|
|
219
|
+
|
|
220
|
+
provider: str # "claude-code" | "codex"
|
|
221
|
+
path: Path # target settings 檔
|
|
222
|
+
applicable: bool # 這個 target 目前適用嗎(Codex:~/.codex 是否存在)
|
|
223
|
+
installed: bool # 檔內是否已有 RiNG hook 條目
|
|
224
|
+
exists: bool # 設定檔本身是否存在
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _has_ring_entry(groups: list[Any]) -> bool:
|
|
228
|
+
"""掃 hook groups,回傳是否有任何 _is_ring_hook_command 命中的條目。"""
|
|
229
|
+
for g in groups:
|
|
230
|
+
if not isinstance(g, dict):
|
|
231
|
+
continue
|
|
232
|
+
for h in g.get("hooks", []):
|
|
233
|
+
if _is_ring_hook_command(h.get("command", "")):
|
|
234
|
+
return True
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def hook_status() -> list[HookStatus]:
|
|
239
|
+
"""逐一檢視 claude-code / codex 兩個 provider,回報每個的安裝狀態(唯讀,不寫檔)。
|
|
240
|
+
|
|
241
|
+
無條件回報兩個 provider(claude-code 永遠 applicable=True,codex 視 ~/.codex 而定);
|
|
242
|
+
讀檔失敗(檔不存在 / 非合法 JSON)一律當 installed=False,exists 照實填,不拋例外。
|
|
243
|
+
"""
|
|
244
|
+
home = Path.home()
|
|
245
|
+
providers = [
|
|
246
|
+
{
|
|
247
|
+
"provider": "claude-code",
|
|
248
|
+
"path": home / ".claude" / "settings.json",
|
|
249
|
+
"applicable": True,
|
|
250
|
+
"events": _HOOK_EVENTS,
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
"provider": "codex",
|
|
254
|
+
"path": home / ".codex" / "hooks.json",
|
|
255
|
+
"applicable": (home / ".codex").is_dir(),
|
|
256
|
+
"events": _CODEX_HOOK_EVENTS,
|
|
257
|
+
},
|
|
258
|
+
]
|
|
259
|
+
result: list[HookStatus] = []
|
|
260
|
+
for p in providers:
|
|
261
|
+
path = p["path"]
|
|
262
|
+
assert isinstance(path, Path)
|
|
263
|
+
exists = path.exists()
|
|
264
|
+
installed = False
|
|
265
|
+
if exists:
|
|
266
|
+
try:
|
|
267
|
+
data: dict[str, Any] = json.loads(path.read_text() or "{}")
|
|
268
|
+
hooks = data.get("hooks", {})
|
|
269
|
+
events = p["events"]
|
|
270
|
+
assert isinstance(events, list)
|
|
271
|
+
installed = any(_has_ring_entry(list(hooks.get(event) or [])) for event in events)
|
|
272
|
+
except Exception:
|
|
273
|
+
installed = False
|
|
274
|
+
result.append(
|
|
275
|
+
HookStatus(
|
|
276
|
+
provider=str(p["provider"]),
|
|
277
|
+
path=path,
|
|
278
|
+
applicable=bool(p["applicable"]),
|
|
279
|
+
installed=installed,
|
|
280
|
+
exists=exists,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _is_ring_hook_command(cmd: str) -> bool:
|
|
287
|
+
"""判斷一條 command 字串是否為 RiNG 自己安裝的 hook 條目。
|
|
288
|
+
|
|
289
|
+
判定規則(涵蓋新舊兩種形式,也涵蓋帶 provider 參數的形式):
|
|
290
|
+
- 把 command 以空白切成 tokens;
|
|
291
|
+
- 前兩個 tokens 是 ``ring hook``(第一個可為 full path)。
|
|
292
|
+
|
|
293
|
+
涵蓋:``"ring hook"``、``"/usr/local/bin/ring hook"``、``"ring hook --provider codex"``。
|
|
294
|
+
不命中:``"some-other-tool hook"``、``"ring"``、``""``、別人裝的任意 command。
|
|
295
|
+
"""
|
|
296
|
+
tokens = cmd.split()
|
|
297
|
+
if len(tokens) < 2:
|
|
298
|
+
return False
|
|
299
|
+
return os.path.basename(tokens[0]) == "ring" and tokens[1] == "hook"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# install-hooks 會就「使用者互動」事件警告:別的工具若也掛在這上面(彈自己的對話框 /
|
|
303
|
+
# 通知),會跟 RiNG 的通知重複觸發。其餘事件(PostToolUse 掛 formatter 之類)是正常
|
|
304
|
+
# 共存,不警告。
|
|
305
|
+
_CONFLICT_EVENTS = ("PermissionRequest", "Notification")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _coresident_handlers(hooks: dict[str, Any]) -> list[str]:
|
|
309
|
+
"""列出掛在「使用者互動」事件上、非 RiNG 的 command(去重、保序)。
|
|
310
|
+
|
|
311
|
+
這些 command 會跟 RiNG 在同一批事件觸發;若想完全改用 RiNG,通常得移除它們,
|
|
312
|
+
免得通知 / dialog 重複。回傳空清單代表沒有衝突。
|
|
313
|
+
"""
|
|
314
|
+
seen: list[str] = []
|
|
315
|
+
for event in _CONFLICT_EVENTS:
|
|
316
|
+
for g in hooks.get(event) or []:
|
|
317
|
+
if not isinstance(g, dict):
|
|
318
|
+
continue
|
|
319
|
+
for h in g.get("hooks", []):
|
|
320
|
+
cmd = h.get("command", "")
|
|
321
|
+
if cmd and not _is_ring_hook_command(cmd) and cmd not in seen:
|
|
322
|
+
seen.append(cmd)
|
|
323
|
+
return seen
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _print_conflict_warning(hooks: dict[str, Any]) -> None:
|
|
327
|
+
"""偵測到其他工具也掛在使用者互動事件上時,提醒它們會跟 RiNG 重複觸發。"""
|
|
328
|
+
conflicts = _coresident_handlers(hooks)
|
|
329
|
+
if not conflicts:
|
|
330
|
+
return
|
|
331
|
+
print(_("⚠️ 偵測到其他工具也掛在 {events}:{cmds}", events=", ".join(_CONFLICT_EVENTS), cmds=", ".join(conflicts)))
|
|
332
|
+
print(" " + _("它們會跟 RiNG 的通知重複觸發;要完全改用 RiNG,建議移除它們。"))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _remove_ring_hooks_from_groups(groups: list[Any]) -> tuple[list[Any], bool]:
|
|
336
|
+
"""從 hook groups 列表中移除所有 _is_ring_hook_command 命中的條目,並清掉變空的 group。
|
|
337
|
+
|
|
338
|
+
回傳 (cleaned_groups, was_changed):
|
|
339
|
+
- cleaned_groups:移除後的 groups(因此變空的 group 也被移除);
|
|
340
|
+
- was_changed:是否有任何條目被移除。
|
|
341
|
+
"""
|
|
342
|
+
new_groups = []
|
|
343
|
+
changed = False
|
|
344
|
+
for g in groups:
|
|
345
|
+
if not isinstance(g, dict):
|
|
346
|
+
new_groups.append(g)
|
|
347
|
+
continue
|
|
348
|
+
hooks_in_g = g.get("hooks", [])
|
|
349
|
+
filtered = [h for h in hooks_in_g if not _is_ring_hook_command(h.get("command", ""))]
|
|
350
|
+
if len(filtered) < len(hooks_in_g):
|
|
351
|
+
changed = True
|
|
352
|
+
# 有條目被移除:若還有其他 hook 保留就縮減;group 清空就整個丟掉
|
|
353
|
+
if filtered:
|
|
354
|
+
new_groups.append({**g, "hooks": filtered})
|
|
355
|
+
# else: group 清空,不加回
|
|
356
|
+
else:
|
|
357
|
+
new_groups.append(g)
|
|
358
|
+
return new_groups, changed
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def install_hooks(dry_run: bool = False) -> int:
|
|
362
|
+
"""把 RiNG 的 hook 註冊進 Claude(~/.claude/settings.json)與 Codex(~/.codex/hooks.json,
|
|
363
|
+
僅在 ~/.codex 存在時)。合併,不覆蓋既有 hooks。任一目標失敗回非 0。"""
|
|
364
|
+
set_lang(get_config().lang)
|
|
365
|
+
rc = 0
|
|
366
|
+
for target in _hook_targets():
|
|
367
|
+
rc |= _install_target(target, dry_run)
|
|
368
|
+
return rc
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _install_target(target: _HookTarget, dry_run: bool) -> int:
|
|
372
|
+
"""把 RiNG hook 合併進單一 target 的 settings 檔。"""
|
|
373
|
+
settings = target.path
|
|
374
|
+
cmd = target.command
|
|
375
|
+
data: dict[str, Any] = {}
|
|
376
|
+
if settings.exists():
|
|
377
|
+
try:
|
|
378
|
+
data = json.loads(settings.read_text() or "{}")
|
|
379
|
+
except json.JSONDecodeError:
|
|
380
|
+
print(_("⚠️ {path} 不是合法 JSON,先處理它再來。", path=settings))
|
|
381
|
+
return 1
|
|
382
|
+
|
|
383
|
+
hooks = data.setdefault("hooks", {})
|
|
384
|
+
|
|
385
|
+
# 第一輪:掃描各 event,判斷是否需要變更(已有完全正確 cmd 且無舊 full-path 形式 → 不動)。
|
|
386
|
+
events_need_change: list[str] = []
|
|
387
|
+
for event in target.events:
|
|
388
|
+
groups = list(hooks.get(event) or [])
|
|
389
|
+
# 「已正確」=有條目 cmd 完全相符且 timeout 已是現值。timeout 不同(例如舊版裝的 10)
|
|
390
|
+
# 也算需要更新,這樣 install-hooks 能自我修復過時的 timeout。
|
|
391
|
+
already_exact = any(
|
|
392
|
+
h.get("command") == cmd and h.get("timeout") == _HOOK_TIMEOUT
|
|
393
|
+
for g in groups
|
|
394
|
+
if isinstance(g, dict)
|
|
395
|
+
for h in g.get("hooks", [])
|
|
396
|
+
)
|
|
397
|
+
has_old_path = any(
|
|
398
|
+
_is_ring_hook_command(h.get("command", "")) and h.get("command") != cmd
|
|
399
|
+
for g in groups
|
|
400
|
+
if isinstance(g, dict)
|
|
401
|
+
for h in g.get("hooks", [])
|
|
402
|
+
)
|
|
403
|
+
if not already_exact or has_old_path:
|
|
404
|
+
events_need_change.append(event)
|
|
405
|
+
|
|
406
|
+
if dry_run:
|
|
407
|
+
for event in target.events:
|
|
408
|
+
groups = list(hooks.get(event) or [])
|
|
409
|
+
cleaned, _changed = _remove_ring_hooks_from_groups(groups)
|
|
410
|
+
# timeout 給足:notify_backend="agent-hooks" 時權限 modal 會 block 到使用者作答。
|
|
411
|
+
cleaned.append({"hooks": [{"type": "command", "command": cmd, "timeout": _HOOK_TIMEOUT}]})
|
|
412
|
+
hooks[event] = cleaned
|
|
413
|
+
print(f"# dry-run → {settings}\n")
|
|
414
|
+
print(json.dumps({"hooks": {e: hooks[e] for e in target.events}}, indent=2, ensure_ascii=False))
|
|
415
|
+
return 0
|
|
416
|
+
|
|
417
|
+
if not events_need_change:
|
|
418
|
+
print(_("✅ RiNG hooks 已經裝過了,沒有變更。({path})", path=settings))
|
|
419
|
+
_print_conflict_warning(hooks)
|
|
420
|
+
return 0
|
|
421
|
+
|
|
422
|
+
for event in events_need_change:
|
|
423
|
+
groups = list(hooks.get(event) or [])
|
|
424
|
+
cleaned, _changed = _remove_ring_hooks_from_groups(groups)
|
|
425
|
+
# timeout 給足:notify_backend="agent-hooks" 時權限 modal 會 block 到使用者作答。
|
|
426
|
+
cleaned.append({"hooks": [{"type": "command", "command": cmd, "timeout": _HOOK_TIMEOUT}]})
|
|
427
|
+
hooks[event] = cleaned
|
|
428
|
+
|
|
429
|
+
settings.parent.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
settings.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
431
|
+
print(_("✅ 已註冊 RiNG hooks({events})到 {path}", events=", ".join(events_need_change), path=settings))
|
|
432
|
+
print(" " + target.restart_hint)
|
|
433
|
+
_print_conflict_warning(hooks)
|
|
434
|
+
return 0
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def uninstall_hooks(dry_run: bool = False) -> int:
|
|
438
|
+
"""從 Claude 與 Codex(若有)的 settings 移除所有 RiNG hook 條目(對稱於 install_hooks)。"""
|
|
439
|
+
set_lang(get_config().lang)
|
|
440
|
+
rc = 0
|
|
441
|
+
for target in _hook_targets():
|
|
442
|
+
rc |= _uninstall_target(target, dry_run)
|
|
443
|
+
return rc
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _uninstall_target(target: _HookTarget, dry_run: bool) -> int:
|
|
447
|
+
"""從單一 target 的 settings 檔移除 RiNG hook 條目。"""
|
|
448
|
+
settings = target.path
|
|
449
|
+
if not settings.exists():
|
|
450
|
+
print(_("ℹ️ {path} 不存在,沒有可移除的 hook。", path=settings))
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
data: dict[str, Any] = json.loads(settings.read_text() or "{}")
|
|
455
|
+
except json.JSONDecodeError:
|
|
456
|
+
print(_("⚠️ {path} 不是合法 JSON,先處理它再來。", path=settings))
|
|
457
|
+
return 1
|
|
458
|
+
|
|
459
|
+
hooks = data.get("hooks", {})
|
|
460
|
+
removed: list[str] = []
|
|
461
|
+
for event in target.events:
|
|
462
|
+
groups = hooks.get(event)
|
|
463
|
+
if not isinstance(groups, list):
|
|
464
|
+
continue
|
|
465
|
+
cleaned, was_removed = _remove_ring_hooks_from_groups(groups)
|
|
466
|
+
if was_removed:
|
|
467
|
+
removed.append(event)
|
|
468
|
+
hooks[event] = cleaned
|
|
469
|
+
|
|
470
|
+
if dry_run:
|
|
471
|
+
print(f"# dry-run → {settings}\n")
|
|
472
|
+
preview_hooks = {e: hooks.get(e, []) for e in target.events}
|
|
473
|
+
print(json.dumps({"hooks": preview_hooks}, indent=2, ensure_ascii=False))
|
|
474
|
+
return 0
|
|
475
|
+
|
|
476
|
+
if not removed:
|
|
477
|
+
print(_("ℹ️ 沒有找到 RiNG hook 條目,無需移除。({path})", path=settings))
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
settings.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
481
|
+
print(_("✅ 已移除 RiNG hooks({events})從 {path}", events=", ".join(removed), path=settings))
|
|
482
|
+
return 0
|