ring-cli 0.2.0__tar.gz → 0.3.0__tar.gz
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_cli-0.2.0 → ring_cli-0.3.0}/PKG-INFO +24 -1
- {ring_cli-0.2.0 → ring_cli-0.3.0}/README.en.md +23 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/pyproject.toml +1 -1
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/cli.py +46 -202
- ring_cli-0.3.0/src/ring/commands/__init__.py +1 -0
- ring_cli-0.3.0/src/ring/commands/_args.py +20 -0
- ring_cli-0.3.0/src/ring/commands/doctor.py +122 -0
- ring_cli-0.3.0/src/ring/commands/focus.py +23 -0
- ring_cli-0.3.0/src/ring/commands/gc.py +46 -0
- ring_cli-0.3.0/src/ring/commands/hook.py +29 -0
- ring_cli-0.3.0/src/ring/gc.py +152 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/registry.py +3 -1
- {ring_cli-0.2.0 → ring_cli-0.3.0}/LICENSE +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/__init__.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/__main__.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/config.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/base.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/hook.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/hook_protocol.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/i18n.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/ipc.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/labels.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/locale/ring.pot +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/base.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/command.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/osascript.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/sources/__init__.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/sources/base.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/tui.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.3.0}/src/ring/watcher.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ring-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: RiNG — Realtime Instance Notification Grid for active agent CLI sessions.
|
|
5
5
|
Keywords: claude-code,codex,tui,dashboard,session,monitor,rich
|
|
6
6
|
Author: Wei Lee
|
|
@@ -27,6 +27,10 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
|
|
28
28
|
[台灣漢語](https://github.com/Lee-W/ring/blob/main/README.md) · **English**
|
|
29
29
|
|
|
30
|
+
[](https://pypi.org/project/ring-cli/)
|
|
31
|
+
[](https://pypi.org/project/ring-cli/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
30
34
|
> **R**ealtime **I**nstance **N**otification **G**rid
|
|
31
35
|
> — a local dashboard for active agent-CLI sessions. Claude Code and Codex are built in; other tools can plug in.
|
|
32
36
|
|
|
@@ -93,6 +97,7 @@ Zero-config mode can discover local Claude Code / Codex sessions without setup.
|
|
|
93
97
|
ring install-hooks # merges into Claude Code / Codex hook settings
|
|
94
98
|
ring install-hooks --dry-run # preview without writing
|
|
95
99
|
ring doctor # inspect hooks, notification backends, focusers, and config
|
|
100
|
+
ring gc --dry-run # preview RiNG-owned stale state cleanup
|
|
96
101
|
```
|
|
97
102
|
|
|
98
103
|
Hooks only apply to new sessions, so restart Claude Code / Codex sessions after installing.
|
|
@@ -112,6 +117,8 @@ Hooks only apply to new sessions, so restart Claude Code / Codex sessions after
|
|
|
112
117
|
| `ring config` | Show config path and effective settings |
|
|
113
118
|
| `ring config set KEY VALUE` | Write one config value |
|
|
114
119
|
| `ring doctor` | Read-only environment diagnosis |
|
|
120
|
+
| `ring gc --dry-run` | Preview RiNG-owned stale state cleanup |
|
|
121
|
+
| `ring gc` | Clean RiNG-owned stale state files |
|
|
115
122
|
|
|
116
123
|
## Watch Mode
|
|
117
124
|
|
|
@@ -145,6 +152,22 @@ Without it, RiNG falls back to macOS text notifications without click-to-focus.
|
|
|
145
152
|
Notification sound, repeat timing, and backend selection are configurable; notification backends
|
|
146
153
|
are also pluggable via the `Notifier` extension point.
|
|
147
154
|
|
|
155
|
+
### Cleaning RiNG State Files (`ring gc`)
|
|
156
|
+
|
|
157
|
+
When RiNG receives `SessionEnd`, it removes its own hook registry entry. If an agent crashes or the
|
|
158
|
+
final hook does not run, ended `~/.config/ring/sessions/*.json` files can remain. They are hidden from
|
|
159
|
+
the board by default, and you can remove them with `ring gc`.
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
ring gc --dry-run # preview what would be deleted
|
|
163
|
+
ring gc # delete ended registry files older than 7 days, plus expired IPC files
|
|
164
|
+
ring gc --older-than 1d # use a 1-day threshold
|
|
165
|
+
ring gc --all-ended # delete every registry file currently classified as ended
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`ring gc` only cleans state files RiNG owns under `~/.config/ring/`. It does not touch Claude Code /
|
|
169
|
+
Codex transcripts or state. `ring doctor` remains read-only and never deletes files.
|
|
170
|
+
|
|
148
171
|
## Session Sources
|
|
149
172
|
|
|
150
173
|
RiNG collects sessions from registered sources. Built-ins:
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[台灣漢語](https://github.com/Lee-W/ring/blob/main/README.md) · **English**
|
|
4
4
|
|
|
5
|
+
[](https://pypi.org/project/ring-cli/)
|
|
6
|
+
[](https://pypi.org/project/ring-cli/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
5
9
|
> **R**ealtime **I**nstance **N**otification **G**rid
|
|
6
10
|
> — a local dashboard for active agent-CLI sessions. Claude Code and Codex are built in; other tools can plug in.
|
|
7
11
|
|
|
@@ -68,6 +72,7 @@ Zero-config mode can discover local Claude Code / Codex sessions without setup.
|
|
|
68
72
|
ring install-hooks # merges into Claude Code / Codex hook settings
|
|
69
73
|
ring install-hooks --dry-run # preview without writing
|
|
70
74
|
ring doctor # inspect hooks, notification backends, focusers, and config
|
|
75
|
+
ring gc --dry-run # preview RiNG-owned stale state cleanup
|
|
71
76
|
```
|
|
72
77
|
|
|
73
78
|
Hooks only apply to new sessions, so restart Claude Code / Codex sessions after installing.
|
|
@@ -87,6 +92,8 @@ Hooks only apply to new sessions, so restart Claude Code / Codex sessions after
|
|
|
87
92
|
| `ring config` | Show config path and effective settings |
|
|
88
93
|
| `ring config set KEY VALUE` | Write one config value |
|
|
89
94
|
| `ring doctor` | Read-only environment diagnosis |
|
|
95
|
+
| `ring gc --dry-run` | Preview RiNG-owned stale state cleanup |
|
|
96
|
+
| `ring gc` | Clean RiNG-owned stale state files |
|
|
90
97
|
|
|
91
98
|
## Watch Mode
|
|
92
99
|
|
|
@@ -120,6 +127,22 @@ Without it, RiNG falls back to macOS text notifications without click-to-focus.
|
|
|
120
127
|
Notification sound, repeat timing, and backend selection are configurable; notification backends
|
|
121
128
|
are also pluggable via the `Notifier` extension point.
|
|
122
129
|
|
|
130
|
+
### Cleaning RiNG State Files (`ring gc`)
|
|
131
|
+
|
|
132
|
+
When RiNG receives `SessionEnd`, it removes its own hook registry entry. If an agent crashes or the
|
|
133
|
+
final hook does not run, ended `~/.config/ring/sessions/*.json` files can remain. They are hidden from
|
|
134
|
+
the board by default, and you can remove them with `ring gc`.
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
ring gc --dry-run # preview what would be deleted
|
|
138
|
+
ring gc # delete ended registry files older than 7 days, plus expired IPC files
|
|
139
|
+
ring gc --older-than 1d # use a 1-day threshold
|
|
140
|
+
ring gc --all-ended # delete every registry file currently classified as ended
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`ring gc` only cleans state files RiNG owns under `~/.config/ring/`. It does not touch Claude Code /
|
|
144
|
+
Codex transcripts or state. `ring doctor` remains read-only and never deletes files.
|
|
145
|
+
|
|
123
146
|
## Session Sources
|
|
124
147
|
|
|
125
148
|
RiNG collects sessions from registered sources. Built-ins:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# import 的 module(見 [tool.uv.build-backend] module-name)與 CLI 指令仍是 `ring`。
|
|
3
3
|
[project]
|
|
4
4
|
name = "ring-cli"
|
|
5
|
-
version = "0.
|
|
5
|
+
version = "0.3.0"
|
|
6
6
|
description = "RiNG — Realtime Instance Notification Grid for active agent CLI sessions."
|
|
7
7
|
readme = "README.en.md"
|
|
8
8
|
requires-python = ">=3.13"
|
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
|
-
import shutil
|
|
14
13
|
import sys
|
|
15
14
|
import time
|
|
15
|
+
from dataclasses import fields
|
|
16
16
|
from importlib.util import find_spec
|
|
17
17
|
from typing import Any
|
|
18
18
|
|
|
19
19
|
from ring import __version__
|
|
20
|
+
from ring.commands._args import strip_lang as _strip_lang
|
|
21
|
+
from ring.commands.doctor import run_doctor
|
|
22
|
+
from ring.commands.focus import run_focus
|
|
23
|
+
from ring.commands.gc import run_gc
|
|
24
|
+
from ring.commands.hook import run_hook_command, run_install_hooks, run_remove_hooks
|
|
20
25
|
from ring.config import CONFIG_PATH as CONFIG_PATH
|
|
21
26
|
from ring.config import Config as Config
|
|
22
27
|
from ring.config import ConfigError as ConfigError
|
|
@@ -26,7 +31,7 @@ from ring.i18n import gettext as _
|
|
|
26
31
|
from ring.i18n import ngettext, set_lang
|
|
27
32
|
from ring.labels import load_labels
|
|
28
33
|
from ring.registry import Session, Status, running_agent_pids
|
|
29
|
-
from ring.sources import discover_sessions
|
|
34
|
+
from ring.sources import discover_sessions
|
|
30
35
|
|
|
31
36
|
try:
|
|
32
37
|
from rich.box import SIMPLE_HEAD
|
|
@@ -74,14 +79,14 @@ def _middle_truncate(text: str, max_len: int) -> str:
|
|
|
74
79
|
tmux 座標(如 ``main:1.0``)很短,自然走這條,不被動到。
|
|
75
80
|
|
|
76
81
|
2. 否則以 ``/`` 為界,保留最後一層目錄完整:
|
|
77
|
-
``tail = "/
|
|
82
|
+
``tail = f"/{text.rsplit('/', 1)[-1]}"``,
|
|
78
83
|
``head_budget = max_len - 1 - len(tail)``(1 = ``…`` 的長度)。
|
|
79
|
-
若 ``head_budget >= 1``:回傳 ``text[:head_budget]
|
|
84
|
+
若 ``head_budget >= 1``:回傳 ``f"{text[:head_budget]}…{tail}"``。
|
|
80
85
|
|
|
81
86
|
3. ``head_budget < 1``(最後一層目錄名本身就超長,或 text 無 ``/``):
|
|
82
87
|
退化為純字元中段省略——
|
|
83
88
|
``keep = max_len - 1``;``front = (keep + 1) // 2``;``back = keep // 2``;
|
|
84
|
-
回傳 ``text[:front]
|
|
89
|
+
回傳 ``f"{text[:front]}…{text[-back:]}"``(總長 == max_len)。
|
|
85
90
|
|
|
86
91
|
4. ``max_len <= 1`` 邊界:直接回傳 ``text[:max_len]``,避免負數切片。
|
|
87
92
|
|
|
@@ -92,17 +97,17 @@ def _middle_truncate(text: str, max_len: int) -> str:
|
|
|
92
97
|
if len(text) <= max_len:
|
|
93
98
|
return text
|
|
94
99
|
# 以 / 為界,保留最後一層
|
|
95
|
-
tail = "/
|
|
100
|
+
tail = f"/{text.rsplit('/', 1)[-1]}"
|
|
96
101
|
head_budget = max_len - 1 - len(tail) # 1 for "…"
|
|
97
102
|
if head_budget >= 1:
|
|
98
|
-
return text[:head_budget]
|
|
103
|
+
return f"{text[:head_budget]}…{tail}"
|
|
99
104
|
# 病態長尾段 fallback:純字元中段省略
|
|
100
105
|
keep = max_len - 1
|
|
101
106
|
front = (keep + 1) // 2
|
|
102
107
|
back = keep // 2
|
|
103
108
|
if back == 0:
|
|
104
109
|
return text[:max_len]
|
|
105
|
-
return text[:front]
|
|
110
|
+
return f"{text[:front]}…{text[-back:]}"
|
|
106
111
|
|
|
107
112
|
|
|
108
113
|
def board(show_all: bool) -> list[Session]:
|
|
@@ -158,7 +163,7 @@ def _rich_renderable(sessions: list[Session], show_legend: bool, show_tool: bool
|
|
|
158
163
|
if show_legend:
|
|
159
164
|
blocks.append(_rich_legend())
|
|
160
165
|
if not sessions:
|
|
161
|
-
blocks.append(Text("
|
|
166
|
+
blocks.append(Text(f" {_('(場館目前沒人上台)')}", style=f"{_MUTED} italic"))
|
|
162
167
|
return Group(*blocks)
|
|
163
168
|
|
|
164
169
|
table = Table(box=SIMPLE_HEAD, header_style="bold", pad_edge=False, expand=False)
|
|
@@ -196,7 +201,7 @@ def _render_plain(sessions: list[Session], show_legend: bool, show_tool: bool =
|
|
|
196
201
|
items = " ".join(f"{st.marker} {status_label(st)}" for st in Status)
|
|
197
202
|
lines += ["", f" {_('圖例')} {items}"]
|
|
198
203
|
if not sessions:
|
|
199
|
-
lines += ["", "
|
|
204
|
+
lines += ["", f" {_('(場館目前沒人上台)')}"]
|
|
200
205
|
return "\n".join(lines)
|
|
201
206
|
|
|
202
207
|
labels = load_labels()
|
|
@@ -219,16 +224,12 @@ def _render_plain(sessions: list[Session], show_legend: bool, show_tool: bool =
|
|
|
219
224
|
w_ago = max(len(c_idle), 3, *(len(r[4]) for r in rows))
|
|
220
225
|
w_loc = max(len(c_loc), *(len(r[5]) for r in rows))
|
|
221
226
|
tool_h = f"{c_tool:<{w_tool}} " if show_tool else ""
|
|
222
|
-
header =
|
|
223
|
-
f" {tool_h}{c_proj:<{w_proj}} {c_prog:>{w_prog}} "
|
|
224
|
-
f"{c_idle:>{w_ago}} {c_loc:<{w_loc}} {c_act}"
|
|
225
|
-
)
|
|
227
|
+
header = f" {tool_h}{c_proj:<{w_proj}} {c_prog:>{w_prog}} {c_idle:>{w_ago}} {c_loc:<{w_loc}} {c_act}"
|
|
226
228
|
lines += ["", header]
|
|
227
229
|
for marker, tool, project, prog, ago, loc, action in rows:
|
|
228
230
|
tool_c = f"{tool:<{w_tool}} " if show_tool else ""
|
|
229
231
|
lines.append(
|
|
230
|
-
f" {marker} {tool_c}{project:<{w_proj}} {prog:>{w_prog}} "
|
|
231
|
-
f"{ago:>{w_ago}} 📍{loc:<{w_loc}} {action}"
|
|
232
|
+
f" {marker} {tool_c}{project:<{w_proj}} {prog:>{w_prog}} {ago:>{w_ago}} 📍{loc:<{w_loc}} {action}"
|
|
232
233
|
)
|
|
233
234
|
return "\n".join(lines)
|
|
234
235
|
|
|
@@ -247,7 +248,7 @@ def _format_config_value(value: object) -> str:
|
|
|
247
248
|
if value is None:
|
|
248
249
|
return "—"
|
|
249
250
|
if isinstance(value, tuple):
|
|
250
|
-
return "[
|
|
251
|
+
return f"[{', '.join(str(v) for v in value)}]" if value else _("(內建預設)")
|
|
251
252
|
if isinstance(value, dict):
|
|
252
253
|
return ", ".join(f"{k}={v}" for k, v in value.items())
|
|
253
254
|
return str(value)
|
|
@@ -259,8 +260,6 @@ def print_config() -> None:
|
|
|
259
260
|
欄位直接從 ``Config`` dataclass 列舉,所以新增設定不必再動這裡。值跟內建預設
|
|
260
261
|
不同的會標一個箭頭,讓你一眼看出「我改過哪些」。
|
|
261
262
|
"""
|
|
262
|
-
from dataclasses import fields
|
|
263
|
-
|
|
264
263
|
cfg = get_config()
|
|
265
264
|
defaults = Config()
|
|
266
265
|
exists = CONFIG_PATH.exists()
|
|
@@ -278,13 +277,11 @@ def print_config() -> None:
|
|
|
278
277
|
print(f" {f.name:<{width}} {_format_config_value(value)}{mark}")
|
|
279
278
|
print()
|
|
280
279
|
hint = _("用 `ring config set KEY VALUE` 改,或直接編輯上面那個檔;完整選項見 src/ring/config.py 的 docstring。")
|
|
281
|
-
print(" "
|
|
280
|
+
print(f" {hint}")
|
|
282
281
|
|
|
283
282
|
|
|
284
283
|
def _config_get_value(key: str) -> object:
|
|
285
284
|
"""讀單一設定的目前生效值(支援 colors.<name> 點記法)。未知鍵丟 ConfigError。"""
|
|
286
|
-
from dataclasses import fields
|
|
287
|
-
|
|
288
285
|
cfg = get_config()
|
|
289
286
|
if "." in key:
|
|
290
287
|
table, sub = key.split(".", 1)
|
|
@@ -296,23 +293,6 @@ def _config_get_value(key: str) -> object:
|
|
|
296
293
|
raise ConfigError(_("未知的鍵:{key}", key=key))
|
|
297
294
|
|
|
298
295
|
|
|
299
|
-
def _strip_lang(args: list[str]) -> list[str]:
|
|
300
|
-
"""濾掉全域 ``--lang`` 旗標(已在 main 先 peek 過),只留 config 自己的位置參數。"""
|
|
301
|
-
out: list[str] = []
|
|
302
|
-
skip = False
|
|
303
|
-
for i, a in enumerate(args):
|
|
304
|
-
if skip:
|
|
305
|
-
skip = False
|
|
306
|
-
continue
|
|
307
|
-
if a == "--lang":
|
|
308
|
-
skip = i + 1 < len(args) # 連同它的值一起跳過
|
|
309
|
-
continue
|
|
310
|
-
if a.startswith("--lang="):
|
|
311
|
-
continue
|
|
312
|
-
out.append(a)
|
|
313
|
-
return out
|
|
314
|
-
|
|
315
|
-
|
|
316
296
|
def run_config(args: list[str]) -> int:
|
|
317
297
|
"""``ring config`` 進入點:無參數→列表;``get KEY``→讀;``set KEY VALUE``→寫。"""
|
|
318
298
|
args = _strip_lang(args)
|
|
@@ -343,136 +323,13 @@ def run_config(args: list[str]) -> int:
|
|
|
343
323
|
print(f"⚠️ {e}", file=sys.stderr)
|
|
344
324
|
return 1
|
|
345
325
|
print(_("✅ 已設定 {key} = {value}({path})", key=key, value=_format_config_value(coerced), path=CONFIG_PATH))
|
|
346
|
-
print("
|
|
326
|
+
print(f" {_('註:set 會重寫整個設定檔,原有註解不會保留。')}")
|
|
347
327
|
return 0
|
|
348
328
|
|
|
349
329
|
print(_("未知的 config 動作:{action}(用 get / set,或不帶參數看目前設定)", action=action), file=sys.stderr)
|
|
350
330
|
return 2
|
|
351
331
|
|
|
352
332
|
|
|
353
|
-
def run_doctor(args: list[str]) -> int:
|
|
354
|
-
"""``ring doctor`` 進入點:唯讀環境診斷,印出五節報告,固定回 0。args 非空回 2。"""
|
|
355
|
-
args = _strip_lang(args)
|
|
356
|
-
if args:
|
|
357
|
-
print(_("用法:ring doctor"), file=sys.stderr)
|
|
358
|
-
return 2
|
|
359
|
-
|
|
360
|
-
from ring.focus import focusers
|
|
361
|
-
from ring.hook import hook_status
|
|
362
|
-
from ring.notify import _select_notifier, notifiers
|
|
363
|
-
from ring.osascript import osascript
|
|
364
|
-
|
|
365
|
-
cfg = get_config()
|
|
366
|
-
|
|
367
|
-
print(_("RiNG 環境診斷"))
|
|
368
|
-
print(f" {_('狀態')}:{_('唯讀檢查,不會改動任何設定')}")
|
|
369
|
-
print()
|
|
370
|
-
|
|
371
|
-
# ── (a) Session 來源 ────────────────────────────────────────────────────
|
|
372
|
-
print(_("Session 來源"))
|
|
373
|
-
src_list = sources()
|
|
374
|
-
if src_list:
|
|
375
|
-
width_src = max(len(s.name) for s in src_list)
|
|
376
|
-
else:
|
|
377
|
-
width_src = 10
|
|
378
|
-
for src in src_list:
|
|
379
|
-
try:
|
|
380
|
-
found = src.discover()
|
|
381
|
-
n = len(found)
|
|
382
|
-
status_str = _("活著")
|
|
383
|
-
count_str = _("偵測到 {n} 個 session", n=n)
|
|
384
|
-
print(f" {src.name:<{width_src}} {status_str} {count_str}")
|
|
385
|
-
except Exception:
|
|
386
|
-
print(f" {src.name:<{width_src}} {_('偵測失敗')}")
|
|
387
|
-
print()
|
|
388
|
-
|
|
389
|
-
# ── (b) Hook 安裝 ────────────────────────────────────────────────────────
|
|
390
|
-
print(_("Hook 安裝"))
|
|
391
|
-
statuses = hook_status()
|
|
392
|
-
provider_labels = {"claude-code": "Claude Code", "codex": "Codex"}
|
|
393
|
-
if statuses:
|
|
394
|
-
width_hook = max(len(provider_labels.get(s.provider, s.provider)) for s in statuses)
|
|
395
|
-
else:
|
|
396
|
-
width_hook = 10
|
|
397
|
-
for hs in statuses:
|
|
398
|
-
label = provider_labels.get(hs.provider, hs.provider)
|
|
399
|
-
if not hs.applicable:
|
|
400
|
-
msg = _("未使用 Codex(zero-config)")
|
|
401
|
-
elif hs.installed:
|
|
402
|
-
msg = _("已安裝")
|
|
403
|
-
else:
|
|
404
|
-
msg = _("未安裝(執行 ring install-hooks)")
|
|
405
|
-
print(f" {label:<{width_hook}} {msg}")
|
|
406
|
-
print()
|
|
407
|
-
|
|
408
|
-
# ── (c) 通知後端 ─────────────────────────────────────────────────────────
|
|
409
|
-
print(_("通知後端"))
|
|
410
|
-
print(f" {_('目前設定')}:{cfg.notify_backend}")
|
|
411
|
-
notifier_list = notifiers()
|
|
412
|
-
if notifier_list:
|
|
413
|
-
width_n = max(len(nt.name) for nt in notifier_list)
|
|
414
|
-
else:
|
|
415
|
-
width_n = 10
|
|
416
|
-
for nt in notifier_list:
|
|
417
|
-
avail_str = _("可用") if nt.available() else _("不可用")
|
|
418
|
-
print(f" {nt.name:<{width_n}} {avail_str}")
|
|
419
|
-
selected = _select_notifier(cfg.notify_backend)
|
|
420
|
-
if selected is not None:
|
|
421
|
-
print(f" {_('auto 實際選中')}:{selected.name}")
|
|
422
|
-
if sys.platform == "darwin" and selected.name in {"terminal-notifier", "osascript"}:
|
|
423
|
-
print(
|
|
424
|
-
" "
|
|
425
|
-
+ _(
|
|
426
|
-
"macOS 提醒:若只聽到聲音但沒有通知框,請到系統設定的通知項目啟用 Banner/Alert。"
|
|
427
|
-
)
|
|
428
|
-
)
|
|
429
|
-
else:
|
|
430
|
-
# 附原因
|
|
431
|
-
if cfg.notify_backend == "none":
|
|
432
|
-
reason = _("backend=none")
|
|
433
|
-
elif cfg.notify_backend == "agent-hooks" and shutil.which("agent-hooks") is not None:
|
|
434
|
-
reason = _("agent-hooks 已接手")
|
|
435
|
-
else:
|
|
436
|
-
reason = _("全部不可用")
|
|
437
|
-
print(f" {_('auto 實際選中')}:{_('不發通知')}({reason})")
|
|
438
|
-
print()
|
|
439
|
-
|
|
440
|
-
# ── (d) 聚焦終端(focuser)───────────────────────────────────────────────
|
|
441
|
-
print(_("聚焦終端(focuser)"))
|
|
442
|
-
focuser_list = focusers()
|
|
443
|
-
if focuser_list:
|
|
444
|
-
width_f = max(len(f.name) for f in focuser_list)
|
|
445
|
-
else:
|
|
446
|
-
width_f = 10
|
|
447
|
-
for f in focuser_list:
|
|
448
|
-
name_lower = f.name.lower()
|
|
449
|
-
if name_lower == "tmux":
|
|
450
|
-
avail = shutil.which("tmux") is not None
|
|
451
|
-
avail_str = _("可用") if avail else _("不可用(tmux 不在 PATH)")
|
|
452
|
-
else:
|
|
453
|
-
# iTerm2 / Terminal:先確認 osascript 在,再問 app 是否跑著
|
|
454
|
-
if shutil.which("osascript") is None:
|
|
455
|
-
avail_str = _("不可用(osascript 不在 PATH)")
|
|
456
|
-
else:
|
|
457
|
-
app_name = f.name # "iTerm2" or "Terminal"
|
|
458
|
-
try:
|
|
459
|
-
rc, out, _err = osascript(f'application "{app_name}" is running')
|
|
460
|
-
avail_str = _("可用") if (rc == 0 and out == "true") else _("不可用(app 沒在跑)")
|
|
461
|
-
except Exception:
|
|
462
|
-
avail_str = _("不可用(app 沒在跑)")
|
|
463
|
-
print(f" {f.name:<{width_f}} {avail_str}")
|
|
464
|
-
print()
|
|
465
|
-
|
|
466
|
-
# ── (e) 設定檔 ───────────────────────────────────────────────────────────
|
|
467
|
-
print(_("設定檔"))
|
|
468
|
-
exists = CONFIG_PATH.exists()
|
|
469
|
-
print(f" {_('路徑')}:{CONFIG_PATH}")
|
|
470
|
-
print(f" {_('狀態')}:{_('已存在') if exists else _('不存在(全部用內建預設)')}")
|
|
471
|
-
print(f" {_('完整生效值請看 `ring config`。')}")
|
|
472
|
-
|
|
473
|
-
return 0
|
|
474
|
-
|
|
475
|
-
|
|
476
333
|
def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
|
|
477
334
|
from ring.notify import notify_waiting
|
|
478
335
|
from ring.watcher import WaitingAlertScheduler
|
|
@@ -494,7 +351,7 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int
|
|
|
494
351
|
except Exception:
|
|
495
352
|
pass
|
|
496
353
|
print(_render_plain(sessions, show_legend, show_tool_column(sessions)))
|
|
497
|
-
print("\n"
|
|
354
|
+
print(f"\n{footer_text}")
|
|
498
355
|
sys.stdout.flush()
|
|
499
356
|
frames += 1
|
|
500
357
|
if count and frames >= count:
|
|
@@ -517,7 +374,7 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int
|
|
|
517
374
|
except Exception:
|
|
518
375
|
pass
|
|
519
376
|
body = _rich_renderable(sessions, show_legend, show_tool_column(sessions))
|
|
520
|
-
live.update(Group(body, Text("\n"
|
|
377
|
+
live.update(Group(body, Text(f"\n{footer_text}", style=_MUTED)), refresh=True)
|
|
521
378
|
frames += 1
|
|
522
379
|
if count and frames >= count:
|
|
523
380
|
return 0
|
|
@@ -548,7 +405,8 @@ commands:
|
|
|
548
405
|
config get KEY 讀單一設定的目前值
|
|
549
406
|
config set KEY VALUE 寫入單一設定(會重寫設定檔,不保留註解)
|
|
550
407
|
focus SESSION_ID 聚焦指定 session;TUI 在跑時會回到 RiNG 並選中該列
|
|
551
|
-
|
|
408
|
+
gc [--dry-run] 清理 RiNG 自己的 stale 狀態檔
|
|
409
|
+
doctor 顯示環境診斷(唯讀)——hook、通知、focuser、維護提示
|
|
552
410
|
"""
|
|
553
411
|
)
|
|
554
412
|
|
|
@@ -585,12 +443,23 @@ def _subcommand_help(name: str) -> str:
|
|
|
585
443
|
"""usage: ring focus SESSION_ID
|
|
586
444
|
|
|
587
445
|
聚焦指定 session;若 RiNG TUI 正在執行,會回到 TUI 並選中該列。
|
|
446
|
+
"""
|
|
447
|
+
),
|
|
448
|
+
"gc": _(
|
|
449
|
+
"""usage: ring gc [--dry-run] [--older-than DURATION] [--all-ended]
|
|
450
|
+
|
|
451
|
+
清理 RiNG 自己的 stale 狀態檔;不碰 Claude Code / Codex 的 transcript 或 state。
|
|
452
|
+
|
|
453
|
+
options:
|
|
454
|
+
--dry-run 只預覽,不刪檔
|
|
455
|
+
--older-than DURATION 清理超過指定時間的已離場 registry(例如 30m、2h、7d;預設 7d)
|
|
456
|
+
--all-ended 清理所有目前判定已離場的 registry
|
|
588
457
|
"""
|
|
589
458
|
),
|
|
590
459
|
"doctor": _(
|
|
591
460
|
"""usage: ring doctor
|
|
592
461
|
|
|
593
|
-
唯讀環境診斷:逐節報告 hook 安裝狀態、通知後端可用性、focuser
|
|
462
|
+
唯讀環境診斷:逐節報告 hook 安裝狀態、通知後端可用性、focuser 可用性、維護提示與設定檔位置。
|
|
594
463
|
不寫任何檔案、不安裝、不發通知;固定回傳 0。
|
|
595
464
|
"""
|
|
596
465
|
),
|
|
@@ -603,53 +472,28 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
603
472
|
cfg = get_config()
|
|
604
473
|
set_lang(_peek_lang(raw) or cfg.lang) # 在 import ring.tui 前設好,Footer 按鍵說明也跟著語言
|
|
605
474
|
|
|
606
|
-
if
|
|
607
|
-
|
|
475
|
+
if (
|
|
476
|
+
raw
|
|
477
|
+
and raw[0] in {"hook", "install-hooks", "remove-hooks", "config", "focus", "gc", "doctor"}
|
|
478
|
+
and any(arg in {"-h", "--help"} for arg in raw[1:])
|
|
608
479
|
):
|
|
609
480
|
print(_subcommand_help(raw[0]), end="")
|
|
610
481
|
return 0
|
|
611
482
|
|
|
612
483
|
if raw and raw[0] == "hook":
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
provider = "claude-code"
|
|
616
|
-
hook_args = raw[1:]
|
|
617
|
-
if hook_args:
|
|
618
|
-
if hook_args[0] == "--provider" and len(hook_args) >= 2:
|
|
619
|
-
provider = hook_args[1]
|
|
620
|
-
elif hook_args[0].startswith("--provider="):
|
|
621
|
-
provider = hook_args[0].split("=", 1)[1]
|
|
622
|
-
elif not hook_args[0].startswith("-"):
|
|
623
|
-
provider = hook_args[0]
|
|
624
|
-
return run_hook(provider=provider)
|
|
484
|
+
return run_hook_command(raw[1:])
|
|
625
485
|
if raw and raw[0] == "install-hooks":
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
return install_hooks(dry_run="--dry-run" in raw)
|
|
486
|
+
return run_install_hooks(raw[1:])
|
|
629
487
|
if raw and raw[0] == "remove-hooks":
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
return uninstall_hooks(dry_run="--dry-run" in raw)
|
|
488
|
+
return run_remove_hooks(raw[1:])
|
|
633
489
|
if raw and raw[0] == "config":
|
|
634
490
|
return run_config(raw[1:])
|
|
491
|
+
if raw and raw[0] == "gc":
|
|
492
|
+
return run_gc(raw[1:])
|
|
635
493
|
if raw and raw[0] == "doctor":
|
|
636
494
|
return run_doctor(raw[1:])
|
|
637
495
|
if raw and raw[0] == "focus" and len(raw) >= 2:
|
|
638
|
-
|
|
639
|
-
from ring.ipc import read_tui_presence, write_focus_request
|
|
640
|
-
from ring.sources import get_by_id
|
|
641
|
-
|
|
642
|
-
session = get_by_id(raw[1])
|
|
643
|
-
if session is None:
|
|
644
|
-
return 0
|
|
645
|
-
presence = read_tui_presence()
|
|
646
|
-
if presence is not None:
|
|
647
|
-
# TUI 在跑:寫 focus-request,讓 TUI 自己移游標並 activate 視窗。
|
|
648
|
-
write_focus_request(raw[1])
|
|
649
|
-
else:
|
|
650
|
-
# headless(沒有 TUI 在跑):退化回現行行為——直接跳到 claude 所在終端。
|
|
651
|
-
focus_jump(session)
|
|
652
|
-
return 0
|
|
496
|
+
return run_focus(raw[1:])
|
|
653
497
|
|
|
654
498
|
parser = argparse.ArgumentParser(
|
|
655
499
|
prog="ring",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI 子命令 handlers。"""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""CLI argument helpers shared by subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def strip_lang(args: list[str]) -> list[str]:
|
|
7
|
+
"""濾掉全域 ``--lang`` 旗標(已在 main 先 peek 過),只留子命令自己的參數。"""
|
|
8
|
+
out: list[str] = []
|
|
9
|
+
skip = False
|
|
10
|
+
for i, a in enumerate(args):
|
|
11
|
+
if skip:
|
|
12
|
+
skip = False
|
|
13
|
+
continue
|
|
14
|
+
if a == "--lang":
|
|
15
|
+
skip = i + 1 < len(args)
|
|
16
|
+
continue
|
|
17
|
+
if a.startswith("--lang="):
|
|
18
|
+
continue
|
|
19
|
+
out.append(a)
|
|
20
|
+
return out
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""``ring doctor`` command handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ring.commands._args import strip_lang
|
|
9
|
+
from ring.config import CONFIG_PATH, get_config
|
|
10
|
+
from ring.gc import DEFAULT_OLDER_THAN_SECONDS
|
|
11
|
+
from ring.gc import collect_candidates as gc_collect_candidates
|
|
12
|
+
from ring.i18n import gettext as _
|
|
13
|
+
from ring.sources import sources
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_doctor(args: list[str]) -> int:
|
|
17
|
+
"""唯讀環境診斷,印出各節報告,固定回 0。args 非空回 2。"""
|
|
18
|
+
args = strip_lang(args)
|
|
19
|
+
if args:
|
|
20
|
+
print(_("用法:ring doctor"), file=sys.stderr)
|
|
21
|
+
return 2
|
|
22
|
+
|
|
23
|
+
from ring.focus import focusers
|
|
24
|
+
from ring.hook import hook_status
|
|
25
|
+
from ring.notify import _select_notifier, notifiers
|
|
26
|
+
from ring.osascript import osascript
|
|
27
|
+
|
|
28
|
+
cfg = get_config()
|
|
29
|
+
|
|
30
|
+
print(_("RiNG 環境診斷"))
|
|
31
|
+
print(f" {_('狀態')}:{_('唯讀檢查,不會改動任何設定')}")
|
|
32
|
+
print()
|
|
33
|
+
|
|
34
|
+
print(_("Session 來源"))
|
|
35
|
+
src_list = sources()
|
|
36
|
+
width_src = max(len(s.name) for s in src_list) if src_list else 10
|
|
37
|
+
for src in src_list:
|
|
38
|
+
try:
|
|
39
|
+
found = src.discover()
|
|
40
|
+
n = len(found)
|
|
41
|
+
status_str = _("活著")
|
|
42
|
+
count_str = _("偵測到 {n} 個 session", n=n)
|
|
43
|
+
print(f" {src.name:<{width_src}} {status_str} {count_str}")
|
|
44
|
+
except Exception:
|
|
45
|
+
print(f" {src.name:<{width_src}} {_('偵測失敗')}")
|
|
46
|
+
print()
|
|
47
|
+
|
|
48
|
+
print(_("Hook 安裝"))
|
|
49
|
+
statuses = hook_status()
|
|
50
|
+
provider_labels = {"claude-code": "Claude Code", "codex": "Codex"}
|
|
51
|
+
width_hook = max(len(provider_labels.get(s.provider, s.provider)) for s in statuses) if statuses else 10
|
|
52
|
+
for hs in statuses:
|
|
53
|
+
label = provider_labels.get(hs.provider, hs.provider)
|
|
54
|
+
if not hs.applicable:
|
|
55
|
+
msg = _("未使用 Codex(zero-config)")
|
|
56
|
+
elif hs.installed:
|
|
57
|
+
msg = _("已安裝")
|
|
58
|
+
else:
|
|
59
|
+
msg = _("未安裝(執行 ring install-hooks)")
|
|
60
|
+
print(f" {label:<{width_hook}} {msg}")
|
|
61
|
+
print()
|
|
62
|
+
|
|
63
|
+
print(_("通知後端"))
|
|
64
|
+
print(f" {_('目前設定')}:{cfg.notify_backend}")
|
|
65
|
+
notifier_list = notifiers()
|
|
66
|
+
width_n = max(len(nt.name) for nt in notifier_list) if notifier_list else 10
|
|
67
|
+
for nt in notifier_list:
|
|
68
|
+
avail_str = _("可用") if nt.available() else _("不可用")
|
|
69
|
+
print(f" {nt.name:<{width_n}} {avail_str}")
|
|
70
|
+
selected = _select_notifier(cfg.notify_backend)
|
|
71
|
+
if selected is not None:
|
|
72
|
+
print(f" {_('auto 實際選中')}:{selected.name}")
|
|
73
|
+
if sys.platform == "darwin" and selected.name in {"terminal-notifier", "osascript"}:
|
|
74
|
+
print(f" {_('macOS 提醒:若只聽到聲音但沒有通知框,請到系統設定的通知項目啟用 Banner/Alert。')}")
|
|
75
|
+
else:
|
|
76
|
+
if cfg.notify_backend == "none":
|
|
77
|
+
reason = _("backend=none")
|
|
78
|
+
elif cfg.notify_backend == "agent-hooks" and shutil.which("agent-hooks") is not None:
|
|
79
|
+
reason = _("agent-hooks 已接手")
|
|
80
|
+
else:
|
|
81
|
+
reason = _("全部不可用")
|
|
82
|
+
print(f" {_('auto 實際選中')}:{_('不發通知')}({reason})")
|
|
83
|
+
print()
|
|
84
|
+
|
|
85
|
+
print(_("聚焦終端(focuser)"))
|
|
86
|
+
focuser_list = focusers()
|
|
87
|
+
width_f = max(len(f.name) for f in focuser_list) if focuser_list else 10
|
|
88
|
+
for f in focuser_list:
|
|
89
|
+
name_lower = f.name.lower()
|
|
90
|
+
if name_lower == "tmux":
|
|
91
|
+
avail = shutil.which("tmux") is not None
|
|
92
|
+
avail_str = _("可用") if avail else _("不可用(tmux 不在 PATH)")
|
|
93
|
+
elif shutil.which("osascript") is None:
|
|
94
|
+
avail_str = _("不可用(osascript 不在 PATH)")
|
|
95
|
+
else:
|
|
96
|
+
app_name = f.name
|
|
97
|
+
try:
|
|
98
|
+
rc, out, _err = osascript(f'application "{app_name}" is running')
|
|
99
|
+
avail_str = _("可用") if (rc == 0 and out == "true") else _("不可用(app 沒在跑)")
|
|
100
|
+
except Exception:
|
|
101
|
+
avail_str = _("不可用(app 沒在跑)")
|
|
102
|
+
print(f" {f.name:<{width_f}} {avail_str}")
|
|
103
|
+
print()
|
|
104
|
+
|
|
105
|
+
print(_("維護"))
|
|
106
|
+
try:
|
|
107
|
+
candidates = gc_collect_candidates(older_than=DEFAULT_OLDER_THAN_SECONDS)
|
|
108
|
+
if candidates:
|
|
109
|
+
print(f" {_('可清理')}:{_('{n} 個 RiNG stale 狀態檔(執行 ring gc --dry-run 預覽)', n=len(candidates))}")
|
|
110
|
+
else:
|
|
111
|
+
print(f" {_('可清理')}:{_('沒有 RiNG stale 狀態檔')}")
|
|
112
|
+
except Exception:
|
|
113
|
+
print(f" {_('可清理')}:{_('偵測失敗')}")
|
|
114
|
+
print()
|
|
115
|
+
|
|
116
|
+
print(_("設定檔"))
|
|
117
|
+
exists = CONFIG_PATH.exists()
|
|
118
|
+
print(f" {_('路徑')}:{CONFIG_PATH}")
|
|
119
|
+
print(f" {_('狀態')}:{_('已存在') if exists else _('不存在(全部用內建預設)')}")
|
|
120
|
+
print(f" {_('完整生效值請看 `ring config`。')}")
|
|
121
|
+
|
|
122
|
+
return 0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""``ring focus`` command handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_focus(args: list[str]) -> int:
|
|
7
|
+
if len(args) < 1:
|
|
8
|
+
return 0
|
|
9
|
+
|
|
10
|
+
from ring.focus import jump as focus_jump
|
|
11
|
+
from ring.ipc import read_tui_presence, write_focus_request
|
|
12
|
+
from ring.sources import get_by_id
|
|
13
|
+
|
|
14
|
+
session_id = args[0]
|
|
15
|
+
session = get_by_id(session_id)
|
|
16
|
+
if session is None:
|
|
17
|
+
return 0
|
|
18
|
+
presence = read_tui_presence()
|
|
19
|
+
if presence is not None:
|
|
20
|
+
write_focus_request(session_id)
|
|
21
|
+
else:
|
|
22
|
+
focus_jump(session)
|
|
23
|
+
return 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""``ring gc`` command handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ring.commands._args import strip_lang
|
|
9
|
+
from ring.gc import DEFAULT_OLDER_THAN_SECONDS, parse_duration
|
|
10
|
+
from ring.gc import run_gc as gc_run
|
|
11
|
+
from ring.i18n import gettext as _
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_gc(args: list[str]) -> int:
|
|
15
|
+
"""清理 RiNG 自己的 stale 狀態檔。"""
|
|
16
|
+
args = strip_lang(args)
|
|
17
|
+
|
|
18
|
+
parser = argparse.ArgumentParser(prog="ring gc", description=_("清理 RiNG 自己的 stale 狀態檔。"))
|
|
19
|
+
parser.add_argument("--dry-run", action="store_true", help=_("只預覽,不刪檔"))
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--older-than",
|
|
22
|
+
default="7d",
|
|
23
|
+
metavar="DURATION",
|
|
24
|
+
help=_("清理超過指定時間的已離場 registry(例如 30m、2h、7d;預設 7d)"),
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("--all-ended", action="store_true", help=_("清理所有目前判定已離場的 registry"))
|
|
27
|
+
try:
|
|
28
|
+
ns = parser.parse_args(args)
|
|
29
|
+
older_than = parse_duration(ns.older_than) if ns.older_than else DEFAULT_OLDER_THAN_SECONDS
|
|
30
|
+
except SystemExit as e:
|
|
31
|
+
return e.code if isinstance(e.code, int) else 2
|
|
32
|
+
except ValueError:
|
|
33
|
+
print(_("無效的 --older-than:{value}", value=ns.older_than), file=sys.stderr)
|
|
34
|
+
return 2
|
|
35
|
+
|
|
36
|
+
result = gc_run(older_than=older_than, all_ended=ns.all_ended, dry_run=ns.dry_run)
|
|
37
|
+
action = _("將刪除") if result.dry_run else _("已刪除")
|
|
38
|
+
count = len(result.candidates) if result.dry_run else len(result.deleted)
|
|
39
|
+
print(_("RiNG GC:{action} {count} 個檔案", action=action, count=count))
|
|
40
|
+
shown = result.candidates if result.dry_run else result.deleted
|
|
41
|
+
for candidate in shown:
|
|
42
|
+
prefix = " - " if result.dry_run else " ✓ "
|
|
43
|
+
print(f"{prefix}{candidate.path} ({candidate.reason})")
|
|
44
|
+
for candidate, error in result.errors:
|
|
45
|
+
print(f" ! {candidate.path} ({error})", file=sys.stderr)
|
|
46
|
+
return 1 if result.errors else 0
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Hook-related CLI command handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_hook_command(args: list[str]) -> int:
|
|
7
|
+
from ring.hook import run_hook
|
|
8
|
+
|
|
9
|
+
provider = "claude-code"
|
|
10
|
+
if args:
|
|
11
|
+
if args[0] == "--provider" and len(args) >= 2:
|
|
12
|
+
provider = args[1]
|
|
13
|
+
elif args[0].startswith("--provider="):
|
|
14
|
+
provider = args[0].split("=", 1)[1]
|
|
15
|
+
elif not args[0].startswith("-"):
|
|
16
|
+
provider = args[0]
|
|
17
|
+
return run_hook(provider=provider)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_install_hooks(args: list[str]) -> int:
|
|
21
|
+
from ring.hook import install_hooks
|
|
22
|
+
|
|
23
|
+
return install_hooks(dry_run="--dry-run" in args)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_remove_hooks(args: list[str]) -> int:
|
|
27
|
+
from ring.hook import uninstall_hooks
|
|
28
|
+
|
|
29
|
+
return uninstall_hooks(dry_run="--dry-run" in args)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""RiNG 自有狀態檔清理。
|
|
2
|
+
|
|
3
|
+
只清 RiNG 寫在 ``~/.config/ring/`` 底下的檔案;不碰 Claude Code / Codex 的
|
|
4
|
+
transcript、SQLite state 或任何 provider 原始資料。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ring.ipc import _FOCUS_REQUEST_PATH, _PRESENCE_PATH, _PRESENCE_TTL, _REQUEST_TTL
|
|
15
|
+
from ring.registry import (
|
|
16
|
+
_SESSION_START_SOURCES,
|
|
17
|
+
RING_REGISTRY,
|
|
18
|
+
Status,
|
|
19
|
+
_hook_sessions,
|
|
20
|
+
collect_provider_procs,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DEFAULT_OLDER_THAN_SECONDS = 7 * 24 * 60 * 60
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class GcCandidate:
|
|
28
|
+
path: Path
|
|
29
|
+
reason: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class GcResult:
|
|
34
|
+
candidates: list[GcCandidate]
|
|
35
|
+
deleted: list[GcCandidate]
|
|
36
|
+
errors: list[tuple[GcCandidate, str]]
|
|
37
|
+
dry_run: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_duration(raw: str) -> float:
|
|
41
|
+
"""解析簡單 duration:``30s``、``10m``、``2h``、``7d``,無單位視為秒。"""
|
|
42
|
+
text = raw.strip().lower()
|
|
43
|
+
if not text:
|
|
44
|
+
raise ValueError("empty duration")
|
|
45
|
+
unit = text[-1]
|
|
46
|
+
if unit in {"s", "m", "h", "d"}:
|
|
47
|
+
number = text[:-1]
|
|
48
|
+
factor = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
|
49
|
+
else:
|
|
50
|
+
number = text
|
|
51
|
+
factor = 1
|
|
52
|
+
seconds = float(number) * factor
|
|
53
|
+
if seconds < 0:
|
|
54
|
+
raise ValueError("negative duration")
|
|
55
|
+
return seconds
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def collect_candidates(*, older_than: float, all_ended: bool = False, now: float | None = None) -> list[GcCandidate]:
|
|
59
|
+
"""收集可清理的 RiNG 自有狀態檔。"""
|
|
60
|
+
current = time.time() if now is None else now
|
|
61
|
+
candidates: dict[Path, GcCandidate] = {}
|
|
62
|
+
|
|
63
|
+
for candidate in _registry_candidates(older_than=older_than, all_ended=all_ended, now=current):
|
|
64
|
+
candidates[candidate.path] = candidate
|
|
65
|
+
for candidate in _ipc_candidates(now=current):
|
|
66
|
+
candidates[candidate.path] = candidate
|
|
67
|
+
|
|
68
|
+
return sorted(candidates.values(), key=lambda c: str(c.path))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_gc(
|
|
72
|
+
*, older_than: float = DEFAULT_OLDER_THAN_SECONDS, all_ended: bool = False, dry_run: bool = False
|
|
73
|
+
) -> GcResult:
|
|
74
|
+
candidates = collect_candidates(older_than=older_than, all_ended=all_ended)
|
|
75
|
+
deleted: list[GcCandidate] = []
|
|
76
|
+
errors: list[tuple[GcCandidate, str]] = []
|
|
77
|
+
if dry_run:
|
|
78
|
+
return GcResult(candidates=candidates, deleted=deleted, errors=errors, dry_run=True)
|
|
79
|
+
|
|
80
|
+
for candidate in candidates:
|
|
81
|
+
try:
|
|
82
|
+
candidate.path.unlink(missing_ok=True)
|
|
83
|
+
deleted.append(candidate)
|
|
84
|
+
except OSError as e:
|
|
85
|
+
errors.append((candidate, str(e)))
|
|
86
|
+
return GcResult(candidates=candidates, deleted=deleted, errors=errors, dry_run=False)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _registry_candidates(*, older_than: float, all_ended: bool, now: float) -> list[GcCandidate]:
|
|
90
|
+
if not RING_REGISTRY.is_dir():
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
candidates: list[GcCandidate] = []
|
|
94
|
+
sessions = _hook_sessions(
|
|
95
|
+
procs_by_provider=collect_provider_procs(),
|
|
96
|
+
purge_session_start_phantoms=False,
|
|
97
|
+
)
|
|
98
|
+
ended = {s.session_id: s for s in sessions if s.status is Status.ENDED}
|
|
99
|
+
|
|
100
|
+
for path in RING_REGISTRY.glob("*.json"):
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
103
|
+
except (OSError, json.JSONDecodeError):
|
|
104
|
+
candidates.append(GcCandidate(path, "registry JSON 無法讀取"))
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
provider = str(data.get("provider", "claude-code") or "claude-code")
|
|
108
|
+
if provider in _SESSION_START_SOURCES:
|
|
109
|
+
candidates.append(GcCandidate(path, "舊版 SessionStart 幽靈檔"))
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
sid = str(data.get("session_id", ""))
|
|
113
|
+
session = ended.get(sid)
|
|
114
|
+
if session is None:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
last_active = _last_active(data)
|
|
118
|
+
if all_ended or now - last_active >= older_than:
|
|
119
|
+
candidates.append(GcCandidate(path, "已離場 registry"))
|
|
120
|
+
|
|
121
|
+
return candidates
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _ipc_candidates(*, now: float) -> list[GcCandidate]:
|
|
125
|
+
candidates: list[GcCandidate] = []
|
|
126
|
+
candidates.extend(_stale_json_file(_FOCUS_REQUEST_PATH, ttl=_REQUEST_TTL, now=now, label="focus-request"))
|
|
127
|
+
candidates.extend(_stale_json_file(_PRESENCE_PATH, ttl=_PRESENCE_TTL, now=now, label="tui-presence"))
|
|
128
|
+
return candidates
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _stale_json_file(path: Path, *, ttl: float, now: float, label: str) -> list[GcCandidate]:
|
|
132
|
+
try:
|
|
133
|
+
raw = path.read_text(encoding="utf-8")
|
|
134
|
+
except OSError:
|
|
135
|
+
return []
|
|
136
|
+
try:
|
|
137
|
+
data = json.loads(raw)
|
|
138
|
+
ts = float(data["ts"])
|
|
139
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
140
|
+
return [GcCandidate(path, f"{label} JSON 無法讀取")]
|
|
141
|
+
if now - ts > ttl:
|
|
142
|
+
return [GcCandidate(path, f"{label} 已過期")]
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _last_active(data: object) -> float:
|
|
147
|
+
if not isinstance(data, dict):
|
|
148
|
+
return 0.0
|
|
149
|
+
try:
|
|
150
|
+
return float(data.get("last_active", 0.0))
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
return 0.0
|
|
@@ -771,6 +771,7 @@ def _hook_sessions(
|
|
|
771
771
|
procs: list[tuple[str, str]] | None = None,
|
|
772
772
|
*,
|
|
773
773
|
procs_by_provider: dict[str, list[tuple[str, str]]] | None = None,
|
|
774
|
+
purge_session_start_phantoms: bool = True,
|
|
774
775
|
) -> list[Session]:
|
|
775
776
|
if not RING_REGISTRY.is_dir():
|
|
776
777
|
return []
|
|
@@ -786,7 +787,8 @@ def _hook_sessions(
|
|
|
786
787
|
if provider in _SESSION_START_SOURCES:
|
|
787
788
|
# 舊版 bug 把 SessionStart 的 source(startup/resume/clear/compact)誤當
|
|
788
789
|
# provider,留下無 tty、跳不過去、又永不離場的幽靈列。清掉這種腐壞檔,自我修復。
|
|
789
|
-
|
|
790
|
+
if purge_session_start_phantoms:
|
|
791
|
+
f.unlink(missing_ok=True)
|
|
790
792
|
continue
|
|
791
793
|
out.append(
|
|
792
794
|
Session(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|