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/cli.py
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
"""RiNG CLI 進場口。
|
|
2
|
+
|
|
3
|
+
``ring`` 印一張當下快照(Rich 表格;沒裝 rich 退回樸素版)。
|
|
4
|
+
``ring --watch`` 像 watch 一樣持續刷新。
|
|
5
|
+
``ring --all`` 連已離場的 session 也顯示。
|
|
6
|
+
``ring --no-legend`` 關掉顏色圖例。
|
|
7
|
+
``ring --lang en`` 切語言(也吃 RING_LANG / LANG)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from importlib.util import find_spec
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ring import __version__
|
|
20
|
+
from ring.config import CONFIG_PATH as CONFIG_PATH
|
|
21
|
+
from ring.config import Config as Config
|
|
22
|
+
from ring.config import ConfigError as ConfigError
|
|
23
|
+
from ring.config import get_config as get_config
|
|
24
|
+
from ring.config import set_value as set_value
|
|
25
|
+
from ring.i18n import gettext as _
|
|
26
|
+
from ring.i18n import ngettext, set_lang
|
|
27
|
+
from ring.labels import load_labels
|
|
28
|
+
from ring.registry import Session, Status, running_agent_pids
|
|
29
|
+
from ring.sources import discover_sessions, sources
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from rich.box import SIMPLE_HEAD
|
|
33
|
+
from rich.console import Console, Group
|
|
34
|
+
from rich.live import Live
|
|
35
|
+
from rich.table import Table
|
|
36
|
+
from rich.text import Text
|
|
37
|
+
|
|
38
|
+
HAVE_RICH = True
|
|
39
|
+
except ImportError: # pragma: no cover - fallback path
|
|
40
|
+
HAVE_RICH = False
|
|
41
|
+
|
|
42
|
+
HAVE_TEXTUAL = find_spec("textual") is not None
|
|
43
|
+
|
|
44
|
+
# 狀態 → Rich 樣式(可在 config 的 [colors] 逐項覆寫)。預設避開 dim / ANSI blue(深底會糊)。
|
|
45
|
+
_COLORS = get_config().colors
|
|
46
|
+
_STATUS_STYLE = {
|
|
47
|
+
Status.WAITING: _COLORS["waiting"],
|
|
48
|
+
Status.WORKING: _COLORS["working"],
|
|
49
|
+
Status.IDLE: _COLORS["idle"],
|
|
50
|
+
Status.ENDED: _COLORS["ended"],
|
|
51
|
+
}
|
|
52
|
+
_MUTED = _COLORS["muted"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _rel(seconds: float) -> str:
|
|
56
|
+
s = int(seconds)
|
|
57
|
+
if s < 60:
|
|
58
|
+
return f"{s}s"
|
|
59
|
+
if s < 3600:
|
|
60
|
+
return f"{s // 60}m"
|
|
61
|
+
return f"{s // 3600}h{(s % 3600) // 60:02d}m"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# 「去哪」欄路徑上限,三套渲染共用(Rich / plain / TUI)
|
|
65
|
+
_LOC_MAX = 40
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _middle_truncate(text: str, max_len: int) -> str:
|
|
69
|
+
"""中段省略,保留路徑最後一層目錄完整。
|
|
70
|
+
|
|
71
|
+
截斷規則(四分支):
|
|
72
|
+
|
|
73
|
+
1. ``len(text) <= max_len`` → 原樣回傳。
|
|
74
|
+
tmux 座標(如 ``main:1.0``)很短,自然走這條,不被動到。
|
|
75
|
+
|
|
76
|
+
2. 否則以 ``/`` 為界,保留最後一層目錄完整:
|
|
77
|
+
``tail = "/" + text.rsplit("/", 1)[-1]``,
|
|
78
|
+
``head_budget = max_len - 1 - len(tail)``(1 = ``…`` 的長度)。
|
|
79
|
+
若 ``head_budget >= 1``:回傳 ``text[:head_budget] + "…" + tail``。
|
|
80
|
+
|
|
81
|
+
3. ``head_budget < 1``(最後一層目錄名本身就超長,或 text 無 ``/``):
|
|
82
|
+
退化為純字元中段省略——
|
|
83
|
+
``keep = max_len - 1``;``front = (keep + 1) // 2``;``back = keep // 2``;
|
|
84
|
+
回傳 ``text[:front] + "…" + text[-back:]``(總長 == max_len)。
|
|
85
|
+
|
|
86
|
+
4. ``max_len <= 1`` 邊界:直接回傳 ``text[:max_len]``,避免負數切片。
|
|
87
|
+
|
|
88
|
+
``…`` 用單一字元(U+2026),長度算 1,與 Rich ``overflow="ellipsis"`` 視覺一致。
|
|
89
|
+
"""
|
|
90
|
+
if max_len <= 1:
|
|
91
|
+
return text[:max_len]
|
|
92
|
+
if len(text) <= max_len:
|
|
93
|
+
return text
|
|
94
|
+
# 以 / 為界,保留最後一層
|
|
95
|
+
tail = "/" + text.rsplit("/", 1)[-1]
|
|
96
|
+
head_budget = max_len - 1 - len(tail) # 1 for "…"
|
|
97
|
+
if head_budget >= 1:
|
|
98
|
+
return text[:head_budget] + "…" + tail
|
|
99
|
+
# 病態長尾段 fallback:純字元中段省略
|
|
100
|
+
keep = max_len - 1
|
|
101
|
+
front = (keep + 1) // 2
|
|
102
|
+
back = keep // 2
|
|
103
|
+
if back == 0:
|
|
104
|
+
return text[:max_len]
|
|
105
|
+
return text[:front] + "…" + text[-back:]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def board(show_all: bool) -> list[Session]:
|
|
109
|
+
sessions = discover_sessions()
|
|
110
|
+
if show_all:
|
|
111
|
+
return sessions
|
|
112
|
+
return [s for s in sessions if s.status is not Status.ENDED]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def status_label(status: Status) -> str:
|
|
116
|
+
return {
|
|
117
|
+
Status.WAITING: _("等你"),
|
|
118
|
+
Status.WORKING: _("工作中"),
|
|
119
|
+
Status.IDLE: _("跑完停著"),
|
|
120
|
+
Status.ENDED: _("已離場"),
|
|
121
|
+
}[status]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def provider_label(provider: str) -> str:
|
|
125
|
+
"""把內部 provider 值轉成畫面上的工具名稱(品牌名不翻譯)。"""
|
|
126
|
+
return {"claude": "Claude", "claude-code": "Claude", "codex": "Codex"}.get(
|
|
127
|
+
provider, provider.title() if provider else "—"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def labeled_project(project: str, label: str) -> str:
|
|
132
|
+
"""專案名後接使用者自訂標籤(有的話):``maigo · 重構登入``。"""
|
|
133
|
+
return f"{project} · {label}" if label else project
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def show_tool_column(sessions: list[Session]) -> bool:
|
|
137
|
+
"""有混用工具(>1 種 provider)時才需要工具欄;全是同一種就省掉。"""
|
|
138
|
+
return len({s.provider for s in sessions}) > 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _header(n: int, pids: int) -> str:
|
|
142
|
+
sess = ngettext("{n} 個 session 在場", "{n} 個 session 在場", n, n=n)
|
|
143
|
+
proc = ngettext("{n} 個 agent process 跑著", "{n} 個 agent process 跑著", pids, n=pids)
|
|
144
|
+
return _("🎤 RiNG — {sess} · {proc}", sess=sess, proc=proc)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ----------------------------------------------------------------------------- Rich
|
|
148
|
+
def _rich_legend() -> Text:
|
|
149
|
+
parts = [Text(f"{_('圖例')} ", style=_MUTED)]
|
|
150
|
+
for status in (Status.WAITING, Status.WORKING, Status.IDLE, Status.ENDED):
|
|
151
|
+
parts.append(Text(f"{status.marker} {status_label(status)} ", style=_STATUS_STYLE[status]))
|
|
152
|
+
return Text.assemble(*[(p.plain, p.style) for p in parts])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _rich_renderable(sessions: list[Session], show_legend: bool, show_tool: bool = True) -> Group:
|
|
156
|
+
pids = running_agent_pids()
|
|
157
|
+
blocks: list[Any] = [Text(_header(len(sessions), len(pids)), style="bold")]
|
|
158
|
+
if show_legend:
|
|
159
|
+
blocks.append(_rich_legend())
|
|
160
|
+
if not sessions:
|
|
161
|
+
blocks.append(Text(" " + _("(場館目前沒人上台)"), style=f"{_MUTED} italic"))
|
|
162
|
+
return Group(*blocks)
|
|
163
|
+
|
|
164
|
+
table = Table(box=SIMPLE_HEAD, header_style="bold", pad_edge=False, expand=False)
|
|
165
|
+
table.add_column(_("狀態"), no_wrap=True, min_width=9)
|
|
166
|
+
if show_tool:
|
|
167
|
+
table.add_column(_("工具"), no_wrap=True)
|
|
168
|
+
table.add_column(_("專案"), style=_COLORS["project"], no_wrap=True)
|
|
169
|
+
table.add_column(_("進度"), justify="right", no_wrap=True)
|
|
170
|
+
table.add_column(_("閒置"), justify="right", no_wrap=True)
|
|
171
|
+
table.add_column(_("去哪"), style=_COLORS["location"], no_wrap=True, min_width=16, max_width=_LOC_MAX)
|
|
172
|
+
# action 可能很長:給 max_width 上限,否則 no_wrap 會吃掉整列寬度、把其他欄壓成 0。
|
|
173
|
+
table.add_column(_("動作"), no_wrap=True, overflow="ellipsis", max_width=50)
|
|
174
|
+
|
|
175
|
+
labels = load_labels()
|
|
176
|
+
for s in sessions:
|
|
177
|
+
status_cell = Text(f"{s.status.marker} {status_label(s.status)}", style=_STATUS_STYLE[s.status])
|
|
178
|
+
progress = f"{s.todo[0]}/{s.todo[1]}" if s.todo else "·"
|
|
179
|
+
loc_cell = f"📍{_middle_truncate(s.location, _LOC_MAX)}"
|
|
180
|
+
project_cell = labeled_project(s.project, labels.get(s.session_id, ""))
|
|
181
|
+
cells: list[Any] = [status_cell]
|
|
182
|
+
if show_tool:
|
|
183
|
+
cells.append(provider_label(s.provider))
|
|
184
|
+
cells += [project_cell, progress, _rel(s.idle_for), loc_cell, s.last_action]
|
|
185
|
+
table.add_row(*cells)
|
|
186
|
+
|
|
187
|
+
blocks.append(table)
|
|
188
|
+
return Group(*blocks)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ----------------------------------------------------------------------------- plain fallback
|
|
192
|
+
def _render_plain(sessions: list[Session], show_legend: bool, show_tool: bool = True) -> str:
|
|
193
|
+
pids = running_agent_pids()
|
|
194
|
+
lines = [_header(len(sessions), len(pids))]
|
|
195
|
+
if show_legend:
|
|
196
|
+
items = " ".join(f"{st.marker} {status_label(st)}" for st in Status)
|
|
197
|
+
lines += ["", f" {_('圖例')} {items}"]
|
|
198
|
+
if not sessions:
|
|
199
|
+
lines += ["", " " + _("(場館目前沒人上台)")]
|
|
200
|
+
return "\n".join(lines)
|
|
201
|
+
|
|
202
|
+
labels = load_labels()
|
|
203
|
+
rows = [
|
|
204
|
+
(
|
|
205
|
+
s.status.marker,
|
|
206
|
+
provider_label(s.provider),
|
|
207
|
+
labeled_project(s.project, labels.get(s.session_id, "")),
|
|
208
|
+
f"{s.todo[0]}/{s.todo[1]}" if s.todo else "·",
|
|
209
|
+
_rel(s.idle_for),
|
|
210
|
+
_middle_truncate(s.location, _LOC_MAX),
|
|
211
|
+
s.last_action[:48],
|
|
212
|
+
)
|
|
213
|
+
for s in sessions
|
|
214
|
+
]
|
|
215
|
+
c_tool, c_proj, c_prog, c_idle, c_loc, c_act = _("工具"), _("專案"), _("進度"), _("閒置"), _("去哪"), _("動作")
|
|
216
|
+
w_tool = max(len(c_tool), *(len(r[1]) for r in rows))
|
|
217
|
+
w_proj = max(len(c_proj), *(len(r[2]) for r in rows))
|
|
218
|
+
w_prog = max(len(c_prog), *(len(r[3]) for r in rows))
|
|
219
|
+
w_ago = max(len(c_idle), 3, *(len(r[4]) for r in rows))
|
|
220
|
+
w_loc = max(len(c_loc), *(len(r[5]) for r in rows))
|
|
221
|
+
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
|
+
)
|
|
226
|
+
lines += ["", header]
|
|
227
|
+
for marker, tool, project, prog, ago, loc, action in rows:
|
|
228
|
+
tool_c = f"{tool:<{w_tool}} " if show_tool else ""
|
|
229
|
+
lines.append(
|
|
230
|
+
f" {marker} {tool_c}{project:<{w_proj}} {prog:>{w_prog}} "
|
|
231
|
+
f"{ago:>{w_ago}} 📍{loc:<{w_loc}} {action}"
|
|
232
|
+
)
|
|
233
|
+
return "\n".join(lines)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ----------------------------------------------------------------------------- entry
|
|
237
|
+
def print_snapshot(sessions: list[Session], show_legend: bool) -> None:
|
|
238
|
+
show_tool = show_tool_column(sessions)
|
|
239
|
+
if HAVE_RICH:
|
|
240
|
+
Console().print(_rich_renderable(sessions, show_legend, show_tool))
|
|
241
|
+
else:
|
|
242
|
+
print(_render_plain(sessions, show_legend, show_tool))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _format_config_value(value: object) -> str:
|
|
246
|
+
"""把一個設定值轉成單行可讀字串(None → —、空 tuple → 內建預設、dict → k=v 串)。"""
|
|
247
|
+
if value is None:
|
|
248
|
+
return "—"
|
|
249
|
+
if isinstance(value, tuple):
|
|
250
|
+
return "[" + ", ".join(str(v) for v in value) + "]" if value else _("(內建預設)")
|
|
251
|
+
if isinstance(value, dict):
|
|
252
|
+
return ", ".join(f"{k}={v}" for k, v in value.items())
|
|
253
|
+
return str(value)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def print_config() -> None:
|
|
257
|
+
"""印出設定檔位置與目前生效的所有設定(覆寫過的標 ←)。
|
|
258
|
+
|
|
259
|
+
欄位直接從 ``Config`` dataclass 列舉,所以新增設定不必再動這裡。值跟內建預設
|
|
260
|
+
不同的會標一個箭頭,讓你一眼看出「我改過哪些」。
|
|
261
|
+
"""
|
|
262
|
+
from dataclasses import fields
|
|
263
|
+
|
|
264
|
+
cfg = get_config()
|
|
265
|
+
defaults = Config()
|
|
266
|
+
exists = CONFIG_PATH.exists()
|
|
267
|
+
|
|
268
|
+
print(_("RiNG 設定檔"))
|
|
269
|
+
print(f" {_('路徑')}:{CONFIG_PATH}")
|
|
270
|
+
print(f" {_('狀態')}:{_('已存在') if exists else _('不存在(全部用內建預設)')}")
|
|
271
|
+
print()
|
|
272
|
+
print(_("目前生效的設定(← = 你覆寫過的)"))
|
|
273
|
+
width = max(len(f.name) for f in fields(cfg))
|
|
274
|
+
for f in fields(cfg):
|
|
275
|
+
value = getattr(cfg, f.name)
|
|
276
|
+
overridden = value != getattr(defaults, f.name)
|
|
277
|
+
mark = " ←" if overridden else ""
|
|
278
|
+
print(f" {f.name:<{width}} {_format_config_value(value)}{mark}")
|
|
279
|
+
print()
|
|
280
|
+
hint = _("用 `ring config set KEY VALUE` 改,或直接編輯上面那個檔;完整選項見 src/ring/config.py 的 docstring。")
|
|
281
|
+
print(" " + hint)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _config_get_value(key: str) -> object:
|
|
285
|
+
"""讀單一設定的目前生效值(支援 colors.<name> 點記法)。未知鍵丟 ConfigError。"""
|
|
286
|
+
from dataclasses import fields
|
|
287
|
+
|
|
288
|
+
cfg = get_config()
|
|
289
|
+
if "." in key:
|
|
290
|
+
table, sub = key.split(".", 1)
|
|
291
|
+
if table == "colors" and sub in cfg.colors:
|
|
292
|
+
return cfg.colors[sub]
|
|
293
|
+
raise ConfigError(_("未知的鍵:{key}", key=key))
|
|
294
|
+
if key in {f.name for f in fields(cfg)}:
|
|
295
|
+
return getattr(cfg, key)
|
|
296
|
+
raise ConfigError(_("未知的鍵:{key}", key=key))
|
|
297
|
+
|
|
298
|
+
|
|
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
|
+
def run_config(args: list[str]) -> int:
|
|
317
|
+
"""``ring config`` 進入點:無參數→列表;``get KEY``→讀;``set KEY VALUE``→寫。"""
|
|
318
|
+
args = _strip_lang(args)
|
|
319
|
+
if not args:
|
|
320
|
+
print_config()
|
|
321
|
+
return 0
|
|
322
|
+
|
|
323
|
+
action, rest = args[0], args[1:]
|
|
324
|
+
if action == "get":
|
|
325
|
+
if len(rest) != 1:
|
|
326
|
+
print(_("用法:ring config get KEY"), file=sys.stderr)
|
|
327
|
+
return 2
|
|
328
|
+
try:
|
|
329
|
+
print(_format_config_value(_config_get_value(rest[0])))
|
|
330
|
+
except ConfigError as e:
|
|
331
|
+
print(f"⚠️ {e}", file=sys.stderr)
|
|
332
|
+
return 1
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
if action == "set":
|
|
336
|
+
if len(rest) != 2:
|
|
337
|
+
print(_("用法:ring config set KEY VALUE"), file=sys.stderr)
|
|
338
|
+
return 2
|
|
339
|
+
key, value = rest
|
|
340
|
+
try:
|
|
341
|
+
coerced = set_value(key, value)
|
|
342
|
+
except ConfigError as e:
|
|
343
|
+
print(f"⚠️ {e}", file=sys.stderr)
|
|
344
|
+
return 1
|
|
345
|
+
print(_("✅ 已設定 {key} = {value}({path})", key=key, value=_format_config_value(coerced), path=CONFIG_PATH))
|
|
346
|
+
print(" " + _("註:set 會重寫整個設定檔,原有註解不會保留。"))
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
print(_("未知的 config 動作:{action}(用 get / set,或不帶參數看目前設定)", action=action), file=sys.stderr)
|
|
350
|
+
return 2
|
|
351
|
+
|
|
352
|
+
|
|
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
|
+
def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
|
|
477
|
+
from ring.notify import notify_waiting
|
|
478
|
+
from ring.watcher import WaitingAlertScheduler
|
|
479
|
+
|
|
480
|
+
cfg = get_config()
|
|
481
|
+
frames = 0
|
|
482
|
+
footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
|
|
483
|
+
if not HAVE_RICH:
|
|
484
|
+
scheduler = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
|
|
485
|
+
try:
|
|
486
|
+
while True:
|
|
487
|
+
sys.stdout.write("\033[2J\033[H")
|
|
488
|
+
sessions = board(show_all)
|
|
489
|
+
alerts = scheduler.feed(sessions)
|
|
490
|
+
try:
|
|
491
|
+
hint = notify_waiting(alerts)
|
|
492
|
+
if hint:
|
|
493
|
+
print(hint)
|
|
494
|
+
except Exception:
|
|
495
|
+
pass
|
|
496
|
+
print(_render_plain(sessions, show_legend, show_tool_column(sessions)))
|
|
497
|
+
print("\n" + footer_text)
|
|
498
|
+
sys.stdout.flush()
|
|
499
|
+
frames += 1
|
|
500
|
+
if count and frames >= count:
|
|
501
|
+
return 0
|
|
502
|
+
time.sleep(interval)
|
|
503
|
+
except KeyboardInterrupt:
|
|
504
|
+
return 0
|
|
505
|
+
|
|
506
|
+
console = Console()
|
|
507
|
+
scheduler = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
|
|
508
|
+
try:
|
|
509
|
+
with Live(console=console, screen=True, auto_refresh=False) as live:
|
|
510
|
+
while True:
|
|
511
|
+
sessions = board(show_all)
|
|
512
|
+
alerts = scheduler.feed(sessions)
|
|
513
|
+
try:
|
|
514
|
+
hint = notify_waiting(alerts)
|
|
515
|
+
if hint:
|
|
516
|
+
print(hint)
|
|
517
|
+
except Exception:
|
|
518
|
+
pass
|
|
519
|
+
body = _rich_renderable(sessions, show_legend, show_tool_column(sessions))
|
|
520
|
+
live.update(Group(body, Text("\n" + footer_text, style=_MUTED)), refresh=True)
|
|
521
|
+
frames += 1
|
|
522
|
+
if count and frames >= count:
|
|
523
|
+
return 0
|
|
524
|
+
time.sleep(interval)
|
|
525
|
+
except KeyboardInterrupt:
|
|
526
|
+
return 0
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _peek_lang(raw: list[str]) -> str | None:
|
|
530
|
+
"""在建 argparse 前先抓 --lang,好讓 help 文字也能翻譯。"""
|
|
531
|
+
for i, arg in enumerate(raw):
|
|
532
|
+
if arg == "--lang" and i + 1 < len(raw):
|
|
533
|
+
return raw[i + 1]
|
|
534
|
+
if arg.startswith("--lang="):
|
|
535
|
+
return arg.split("=", 1)[1]
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _commands_help() -> str:
|
|
540
|
+
return _(
|
|
541
|
+
"""
|
|
542
|
+
commands:
|
|
543
|
+
hook [PROVIDER] 從 stdin 讀 provider hook payload,寫入 RiNG registry
|
|
544
|
+
hook --provider PROVIDER 同上,明確指定 provider(例如 codex)
|
|
545
|
+
install-hooks [--dry-run] 安裝 Claude Code / Codex hooks
|
|
546
|
+
remove-hooks [--dry-run] 移除 Claude Code / Codex hooks
|
|
547
|
+
config 顯示設定檔位置與目前生效的設定
|
|
548
|
+
config get KEY 讀單一設定的目前值
|
|
549
|
+
config set KEY VALUE 寫入單一設定(會重寫設定檔,不保留註解)
|
|
550
|
+
focus SESSION_ID 聚焦指定 session;TUI 在跑時會回到 RiNG 並選中該列
|
|
551
|
+
doctor 顯示環境診斷(唯讀)——hook 安裝狀態、通知後端、focuser 可用性
|
|
552
|
+
"""
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _subcommand_help(name: str) -> str:
|
|
557
|
+
helps = {
|
|
558
|
+
"hook": _(
|
|
559
|
+
"""usage: ring hook [PROVIDER] [--provider PROVIDER]
|
|
560
|
+
|
|
561
|
+
從 stdin 讀 hook JSON,依 provider 正規化後寫入 RiNG registry。
|
|
562
|
+
"""
|
|
563
|
+
),
|
|
564
|
+
"install-hooks": _(
|
|
565
|
+
"""usage: ring install-hooks [--dry-run]
|
|
566
|
+
|
|
567
|
+
安裝 Claude Code / Codex hooks。
|
|
568
|
+
"""
|
|
569
|
+
),
|
|
570
|
+
"remove-hooks": _(
|
|
571
|
+
"""usage: ring remove-hooks [--dry-run]
|
|
572
|
+
|
|
573
|
+
從 Claude Code / Codex hook 設定移除 RiNG 安裝的 hooks。
|
|
574
|
+
"""
|
|
575
|
+
),
|
|
576
|
+
"config": _(
|
|
577
|
+
"""usage: ring config [get KEY | set KEY VALUE]
|
|
578
|
+
|
|
579
|
+
不帶參數:顯示設定檔位置(~/.config/ring/config.toml)與目前生效的所有設定。
|
|
580
|
+
get KEY 印出單一設定的目前值(colors 子鍵用 colors.<name>)。
|
|
581
|
+
set KEY VALUE 寫入單一設定。注意:會重寫整個設定檔,原有註解不會保留。
|
|
582
|
+
"""
|
|
583
|
+
),
|
|
584
|
+
"focus": _(
|
|
585
|
+
"""usage: ring focus SESSION_ID
|
|
586
|
+
|
|
587
|
+
聚焦指定 session;若 RiNG TUI 正在執行,會回到 TUI 並選中該列。
|
|
588
|
+
"""
|
|
589
|
+
),
|
|
590
|
+
"doctor": _(
|
|
591
|
+
"""usage: ring doctor
|
|
592
|
+
|
|
593
|
+
唯讀環境診斷:逐節報告 hook 安裝狀態、通知後端可用性、focuser 可用性與設定檔位置。
|
|
594
|
+
不寫任何檔案、不安裝、不發通知;固定回傳 0。
|
|
595
|
+
"""
|
|
596
|
+
),
|
|
597
|
+
}
|
|
598
|
+
return helps.get(name, "")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def main(argv: list[str] | None = None) -> int:
|
|
602
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
603
|
+
cfg = get_config()
|
|
604
|
+
set_lang(_peek_lang(raw) or cfg.lang) # 在 import ring.tui 前設好,Footer 按鍵說明也跟著語言
|
|
605
|
+
|
|
606
|
+
if raw and raw[0] in {"hook", "install-hooks", "remove-hooks", "config", "focus", "doctor"} and any(
|
|
607
|
+
arg in {"-h", "--help"} for arg in raw[1:]
|
|
608
|
+
):
|
|
609
|
+
print(_subcommand_help(raw[0]), end="")
|
|
610
|
+
return 0
|
|
611
|
+
|
|
612
|
+
if raw and raw[0] == "hook":
|
|
613
|
+
from ring.hook import run_hook
|
|
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)
|
|
625
|
+
if raw and raw[0] == "install-hooks":
|
|
626
|
+
from ring.hook import install_hooks
|
|
627
|
+
|
|
628
|
+
return install_hooks(dry_run="--dry-run" in raw)
|
|
629
|
+
if raw and raw[0] == "remove-hooks":
|
|
630
|
+
from ring.hook import uninstall_hooks
|
|
631
|
+
|
|
632
|
+
return uninstall_hooks(dry_run="--dry-run" in raw)
|
|
633
|
+
if raw and raw[0] == "config":
|
|
634
|
+
return run_config(raw[1:])
|
|
635
|
+
if raw and raw[0] == "doctor":
|
|
636
|
+
return run_doctor(raw[1:])
|
|
637
|
+
if raw and raw[0] == "focus" and len(raw) >= 2:
|
|
638
|
+
from ring.focus import jump as focus_jump
|
|
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
|
|
653
|
+
|
|
654
|
+
parser = argparse.ArgumentParser(
|
|
655
|
+
prog="ring",
|
|
656
|
+
description=_("看所有 agent CLI session 上台。"),
|
|
657
|
+
epilog=_commands_help(),
|
|
658
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
659
|
+
)
|
|
660
|
+
parser.add_argument("--version", action="version", version=f"ring {__version__}")
|
|
661
|
+
parser.add_argument("--watch", action="store_true", help=_("持續刷新"))
|
|
662
|
+
parser.add_argument("--interval", type=float, default=cfg.interval, help=_("watch 刷新秒數"))
|
|
663
|
+
parser.add_argument("--count", type=int, default=0, help=_("watch 刷新幾格後自動結束(0=無限,預設)"))
|
|
664
|
+
parser.add_argument("--all", "-a", action="store_true", default=cfg.show_all, help=_("連已離場的 session 也顯示"))
|
|
665
|
+
parser.add_argument(
|
|
666
|
+
"--legend",
|
|
667
|
+
action=argparse.BooleanOptionalAction,
|
|
668
|
+
default=cfg.legend,
|
|
669
|
+
help=_("顯示顏色圖例(--no-legend 關閉)"),
|
|
670
|
+
)
|
|
671
|
+
parser.add_argument("--lang", help=_("語言(如 en / zh-Hant;也吃 config / RING_LANG / LANG)"))
|
|
672
|
+
args = parser.parse_args(raw)
|
|
673
|
+
|
|
674
|
+
if args.watch:
|
|
675
|
+
if HAVE_TEXTUAL and sys.stdout.isatty():
|
|
676
|
+
from ring.tui import run_tui
|
|
677
|
+
|
|
678
|
+
return run_tui(args.interval, args.all)
|
|
679
|
+
return watch(args.interval, args.count, args.all, args.legend)
|
|
680
|
+
print_snapshot(board(args.all), args.legend)
|
|
681
|
+
return 0
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
if __name__ == "__main__":
|
|
685
|
+
sys.exit(main())
|