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/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())