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/config.py ADDED
@@ -0,0 +1,276 @@
1
+ """RiNG 設定檔:``~/.config/ring/config.toml``。
2
+
3
+ 缺檔、缺鍵、型別錯——全部安靜退回預設,所以零設定也能跑。範例:
4
+
5
+ lang = "en"
6
+ interval = 1.5
7
+ show_all = false
8
+ legend = true
9
+ active_window_seconds = 21600 # 只看最近這段時間動過的 session(預設 6h)
10
+ working_threshold_seconds = 90 # 多久沒動就從 🟢 工作中 變 🟡 閒置
11
+ waiting_window_seconds = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
12
+ notify_sound = true # 系統通知帶聲音
13
+ notify_sound_name = "Glass" # macOS / terminal-notifier sound name
14
+ notify_ignore_dnd = false # terminal-notifier 是否加 -ignoreDnD(穿透勿擾 / Focus)
15
+ notify_backend = "auto" # auto / terminal-notifier / osascript / notify-send / agent-hooks / none
16
+ # terminal-notifier 被 macOS 擋掉時設 "osascript"
17
+ # (看得到通知,但點擊不跳轉);
18
+ # "agent-hooks" = 決策+提醒交給 agent-hooks(ring hook 同步出 modal,
19
+ # 沒裝時自動退回 auto 通知);
20
+ # "none" = 完全不發通知(RiNG 當純看板)
21
+ notify_repeat_seconds = [30, 120, 300] # 持續等你時,多久後重複提醒
22
+ notify_repeat_max = 3 # 重複提醒上限;0 = 不限
23
+ focusers = ["tmux", "iTerm2", "Terminal"] # 跳轉嘗試順序;省略=內建預設
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import tomllib
29
+ from collections.abc import Callable
30
+ from dataclasses import dataclass, field
31
+ from functools import lru_cache
32
+ from pathlib import Path
33
+
34
+ CONFIG_PATH = Path.home() / ".config" / "ring" / "config.toml"
35
+
36
+ # Rich 樣式字串。深淺底都看得到(避開 dim / ANSI blue)。config 的 [colors] 可逐項覆寫。
37
+ _DEFAULT_COLORS = {
38
+ "waiting": "bold red",
39
+ "working": "green",
40
+ "idle": "yellow",
41
+ "ended": "grey50",
42
+ "project": "cyan",
43
+ "location": "bright_blue",
44
+ "muted": "grey50",
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Config:
50
+ lang: str | None = None
51
+ interval: float = 2.0
52
+ show_all: bool = False
53
+ legend: bool = True
54
+ active_window_seconds: int = 6 * 60 * 60
55
+ working_threshold_seconds: int = 90
56
+ waiting_window_seconds: int = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
57
+ notify_sound: bool = True
58
+ notify_sound_name: str = "Glass"
59
+ notify_ignore_dnd: bool = False
60
+ # 通知後端(完整說明見模組 docstring):
61
+ # auto = 第一個可用後端(優先支援點擊跳轉的 terminal-notifier);
62
+ # terminal-notifier / osascript / notify-send = 強制指定該後端;
63
+ # agent-hooks = 決策+提醒交給 agent-hooks(沒裝時自動退回 auto);
64
+ # none = 完全不發通知(RiNG 當純看板)。
65
+ notify_backend: str = "auto"
66
+ notify_repeat_seconds: tuple[int, ...] = (30, 120, 300)
67
+ notify_repeat_max: int = 3 # 0 = 不限
68
+ focusers: tuple[str, ...] = () # 空=用內建預設順序
69
+ colors: dict[str, str] = field(default_factory=lambda: dict(_DEFAULT_COLORS))
70
+
71
+
72
+ def _as_int(v: object, default: int) -> int:
73
+ return v if isinstance(v, int) and not isinstance(v, bool) else default
74
+
75
+
76
+ def _as_float(v: object, default: float) -> float:
77
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
78
+ return float(v)
79
+ return default
80
+
81
+
82
+ def _as_bool(v: object, default: bool) -> bool:
83
+ return v if isinstance(v, bool) else default
84
+
85
+
86
+ def _as_str_tuple(v: object) -> tuple[str, ...]:
87
+ if isinstance(v, list):
88
+ return tuple(x for x in v if isinstance(x, str))
89
+ return ()
90
+
91
+
92
+ def _as_positive_int_tuple(v: object, default: tuple[int, ...]) -> tuple[int, ...]:
93
+ if isinstance(v, list):
94
+ parsed = tuple(x for x in v if isinstance(x, int) and not isinstance(x, bool) and x > 0)
95
+ return parsed or default
96
+ return default
97
+
98
+
99
+ def _parse_colors(v: object) -> dict[str, str]:
100
+ colors = dict(_DEFAULT_COLORS)
101
+ if isinstance(v, dict):
102
+ colors.update({k: val for k, val in v.items() if isinstance(k, str) and isinstance(val, str)})
103
+ return colors
104
+
105
+
106
+ def load(path: Path | None = None) -> Config:
107
+ p = path or CONFIG_PATH
108
+ try:
109
+ raw = tomllib.loads(p.read_text())
110
+ except (OSError, tomllib.TOMLDecodeError):
111
+ return Config()
112
+ d = Config()
113
+ lang = raw.get("lang")
114
+ # 任意非空字串都接受(後端名稱由 notify 層的可插拔 registry 決定);認不得的名稱
115
+ # 會在送通知時退回 auto 選法。非字串 / 空字串 → 預設 "auto"。
116
+ nb = raw.get("notify_backend")
117
+ notify_backend = nb if isinstance(nb, str) and nb else d.notify_backend
118
+ return Config(
119
+ lang=lang if isinstance(lang, str) else None,
120
+ interval=_as_float(raw.get("interval"), d.interval),
121
+ show_all=_as_bool(raw.get("show_all"), d.show_all),
122
+ legend=_as_bool(raw.get("legend"), d.legend),
123
+ active_window_seconds=_as_int(raw.get("active_window_seconds"), d.active_window_seconds),
124
+ working_threshold_seconds=_as_int(raw.get("working_threshold_seconds"), d.working_threshold_seconds),
125
+ waiting_window_seconds=_as_int(raw.get("waiting_window_seconds"), d.waiting_window_seconds),
126
+ notify_sound=_as_bool(raw.get("notify_sound"), d.notify_sound),
127
+ notify_sound_name=(
128
+ raw["notify_sound_name"] if isinstance(raw.get("notify_sound_name"), str) else d.notify_sound_name
129
+ ),
130
+ notify_ignore_dnd=_as_bool(raw.get("notify_ignore_dnd"), d.notify_ignore_dnd),
131
+ notify_repeat_seconds=_as_positive_int_tuple(raw.get("notify_repeat_seconds"), d.notify_repeat_seconds),
132
+ notify_repeat_max=max(0, _as_int(raw.get("notify_repeat_max"), d.notify_repeat_max)),
133
+ notify_backend=notify_backend,
134
+ focusers=_as_str_tuple(raw.get("focusers")),
135
+ colors=_parse_colors(raw.get("colors")),
136
+ )
137
+
138
+
139
+ @lru_cache(maxsize=1)
140
+ def get_config() -> Config:
141
+ """整個 process 共用的設定(讀一次)。要繞過快取自訂就直接呼叫 load(path)。"""
142
+ return load()
143
+
144
+
145
+ # --------------------------------------------------------------------------- 寫入(ring config set)
146
+
147
+
148
+ class ConfigError(ValueError):
149
+ """``ring config set/get`` 的使用者層級錯誤(未知鍵 / 值轉型失敗)。訊息直接給使用者看。"""
150
+
151
+
152
+ def _coerce_bool(s: str) -> bool:
153
+ low = s.strip().lower()
154
+ if low in {"true", "1", "yes", "on"}:
155
+ return True
156
+ if low in {"false", "0", "no", "off"}:
157
+ return False
158
+ raise ConfigError(f"'{s}' 不是布林值(用 true / false)")
159
+
160
+
161
+ def _coerce_int(s: str) -> int:
162
+ try:
163
+ return int(s.strip())
164
+ except ValueError:
165
+ raise ConfigError(f"'{s}' 不是整數") from None
166
+
167
+
168
+ def _coerce_float(s: str) -> float:
169
+ try:
170
+ return float(s.strip())
171
+ except ValueError:
172
+ raise ConfigError(f"'{s}' 不是數字") from None
173
+
174
+
175
+ def _coerce_int_list(s: str) -> list[int]:
176
+ return [_coerce_int(part) for part in s.split(",") if part.strip()]
177
+
178
+
179
+ def _coerce_str_list(s: str) -> list[str]:
180
+ return [part.strip() for part in s.split(",") if part.strip()]
181
+
182
+
183
+ # 可由 ``ring config set`` 寫入的純量 / 清單鍵 → 把 CLI 傳來的字串轉成對應型別。
184
+ # colors 是巢狀 table,用 ``colors.<name>`` 點記法另外處理(見 set_value)。
185
+ _SETTERS: dict[str, Callable[[str], object]] = {
186
+ "lang": str,
187
+ "interval": _coerce_float,
188
+ "show_all": _coerce_bool,
189
+ "legend": _coerce_bool,
190
+ "active_window_seconds": _coerce_int,
191
+ "working_threshold_seconds": _coerce_int,
192
+ "waiting_window_seconds": _coerce_int,
193
+ "notify_sound": _coerce_bool,
194
+ "notify_sound_name": str,
195
+ "notify_ignore_dnd": _coerce_bool,
196
+ "notify_backend": str,
197
+ "notify_repeat_seconds": _coerce_int_list,
198
+ "notify_repeat_max": _coerce_int,
199
+ "focusers": _coerce_str_list,
200
+ }
201
+
202
+
203
+ def settable_keys() -> list[str]:
204
+ """``ring config set`` 接受的鍵(colors 子鍵用 colors.<name>,不在此列)。"""
205
+ return list(_SETTERS)
206
+
207
+
208
+ def _toml_repr(value: object) -> str:
209
+ """把一個 Python 值序列化成 TOML 字面值(只涵蓋本設定會用到的型別)。"""
210
+ if isinstance(value, bool):
211
+ return "true" if value else "false"
212
+ if isinstance(value, int):
213
+ return str(value)
214
+ if isinstance(value, float):
215
+ return repr(value)
216
+ if isinstance(value, str):
217
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
218
+ return f'"{escaped}"'
219
+ if isinstance(value, (list, tuple)):
220
+ return "[" + ", ".join(_toml_repr(v) for v in value) + "]"
221
+ raise ConfigError(f"無法序列化 {type(value).__name__} 值")
222
+
223
+
224
+ def dump_toml(data: dict[str, object]) -> str:
225
+ """把設定 dict 寫成 TOML 文字。純量 / 清單在前,巢狀 table(如 [colors])殿後。
226
+
227
+ 刻意樸素:不保留註解(``ring config set`` 重寫整檔),但所有鍵值都會保留。
228
+ """
229
+ lines = [f"{k} = {_toml_repr(v)}" for k, v in data.items() if not isinstance(v, dict)]
230
+ for k, v in data.items():
231
+ if isinstance(v, dict):
232
+ lines.append(f"\n[{k}]")
233
+ lines += [f"{sub} = {_toml_repr(val)}" for sub, val in v.items()]
234
+ return "\n".join(lines) + "\n"
235
+
236
+
237
+ def _read_raw(path: Path) -> dict[str, object]:
238
+ try:
239
+ return dict(tomllib.loads(path.read_text()))
240
+ except FileNotFoundError:
241
+ return {}
242
+ except OSError as e:
243
+ raise ConfigError(f"讀不到 {path}:{e}") from None
244
+ except tomllib.TOMLDecodeError as e:
245
+ raise ConfigError(f"{path} 不是合法 TOML:{e}") from None
246
+
247
+
248
+ def set_value(key: str, raw_value: str, path: Path | None = None) -> object:
249
+ """把 ``key = raw_value`` 寫進設定檔,回傳轉型後的值。
250
+
251
+ ``colors.<name>`` 點記法寫進 [colors] table(字串值)。其餘鍵須在 ``_SETTERS`` 內,
252
+ 依型別轉換。未知鍵 / 轉型失敗丟 ``ConfigError``(訊息給使用者)。重寫整檔(不保留註解)。
253
+ """
254
+ p = path or CONFIG_PATH
255
+ data = _read_raw(p)
256
+
257
+ if "." in key:
258
+ table, sub = key.split(".", 1)
259
+ if table != "colors" or not sub:
260
+ raise ConfigError(f"未知的鍵:{key}")
261
+ colors = data.get("colors")
262
+ colors = dict(colors) if isinstance(colors, dict) else {}
263
+ colors[sub] = raw_value
264
+ data["colors"] = colors
265
+ coerced: object = raw_value
266
+ else:
267
+ setter = _SETTERS.get(key)
268
+ if setter is None:
269
+ raise ConfigError(f"未知的鍵:{key}(可設:{', '.join(_SETTERS)})")
270
+ coerced = setter(raw_value)
271
+ data[key] = coerced
272
+
273
+ p.parent.mkdir(parents=True, exist_ok=True)
274
+ p.write_text(dump_toml(data))
275
+ get_config.cache_clear() # 同 process 內讓下次 get_config() 讀到新值
276
+ return coerced
ring/focus/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """把焦點跳到 session 所在的終端——可插拔、不綁特定 vendor。
2
+
3
+ 每個終端是一個 ``Focuser``(見 ``base.Focuser``):core 不認識任何具體終端,
4
+ 只依序問每個 focuser「這個 session 歸不歸你管」。要支援新終端=寫一個模組、
5
+ ``register_focuser()`` 註冊,core 零改動。內建:tmux / iTerm2 / Terminal.app。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ring.config import get_config
11
+ from ring.focus.base import Focuser
12
+ from ring.focus.iterm2 import focuser as _iterm2
13
+ from ring.focus.terminal import focuser as _terminal
14
+ from ring.focus.tmux import focuser as _tmux
15
+ from ring.i18n import gettext as _
16
+ from ring.registry import Session, Status
17
+
18
+ # 內建 focuser。順序可由 config 的 `focusers` 覆寫。
19
+ _BUILTIN: dict[str, Focuser] = {"tmux": _tmux, "iTerm2": _iterm2, "Terminal": _terminal}
20
+
21
+
22
+ def _initial_focusers() -> list[Focuser]:
23
+ order = get_config().focusers
24
+ if order:
25
+ return [_BUILTIN[name] for name in order if name in _BUILTIN]
26
+ return list(_BUILTIN.values())
27
+
28
+
29
+ _FOCUSERS: list[Focuser] = _initial_focusers()
30
+
31
+
32
+ def register_focuser(focuser: Focuser, *, first: bool = False) -> None:
33
+ """外部擴充入口:註冊一個自訂 focuser(first=True 插到最前、優先嘗試)。"""
34
+ if first:
35
+ _FOCUSERS.insert(0, focuser)
36
+ else:
37
+ _FOCUSERS.append(focuser)
38
+
39
+
40
+ def focusers() -> list[Focuser]:
41
+ """目前已註冊的 focuser,順序即嘗試順序。"""
42
+ return list(_FOCUSERS)
43
+
44
+
45
+ def jump(session: Session) -> tuple[bool, str]:
46
+ """依序問每個 focuser,誰先接手就用誰。回傳 (成功?, 訊息)。"""
47
+ if session.status is Status.ENDED:
48
+ return False, _("已離場的 session 無法跳轉")
49
+ failures: list[str] = []
50
+ for focuser in _FOCUSERS:
51
+ result = focuser.try_focus(session)
52
+ if result is None:
53
+ continue
54
+ ok, msg = result
55
+ if ok:
56
+ return True, msg
57
+ failures.append(f"{focuser.name}: {msg}")
58
+ if failures:
59
+ return False, "; ".join(failures)
60
+ if session.tty:
61
+ return False, _("找不到這個終端分頁(可能已關閉;刷新後若仍存在,請裝 hook 取得更精準狀態)")
62
+ return False, _("沒有 focuser 接得住(裝 hook,或一個專案只開一個 session 才測得到 tty)")
63
+
64
+
65
+ __all__ = ["Focuser", "focusers", "jump", "register_focuser"]
@@ -0,0 +1,34 @@
1
+ """用 tty 在 macOS 終端 app 裡找到分頁並聚焦的共用 focuser 基底。
2
+
3
+ iTerm2 / Terminal.app 共用這套機制(各自提供 AppleScript);tmux 不走這條、自有實作,
4
+ 所以這段「共用實作」放自己的模組,而不是擠進 ``base``(契約)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+
11
+ from ring.osascript import osascript
12
+ from ring.registry import Session
13
+
14
+
15
+ class AppleScriptTTYFocuser:
16
+ """用 session 的 tty 在某個 macOS 終端 app 裡找到對應分頁並聚焦。
17
+
18
+ 各 app 提供自己的 AppleScript(用 ``is running`` 守衛,沒在跑的 app 不會被喚醒)。
19
+ """
20
+
21
+ def __init__(self, app: str, script: str) -> None:
22
+ self.name = app
23
+ self._script = script
24
+
25
+ def try_focus(self, session: Session) -> tuple[bool, str] | None:
26
+ tty = session.tty
27
+ if not tty or not shutil.which("osascript"):
28
+ return None
29
+ rc, out, err = osascript(self._script.format(tty=tty))
30
+ if rc == 0 and out == "ok":
31
+ return True, f"{self.name} {tty}"
32
+ if rc == 0 and out == "notfound":
33
+ return None # 這個 app 沒有這個 tty → 換下一個 focuser
34
+ return False, err or out or f"returncode={rc}"
ring/focus/base.py ADDED
@@ -0,0 +1,19 @@
1
+ """Focuser 協定——core 跟具體終端之間的契約(只放 Protocol,不放具體實作)。
2
+
3
+ ``try_focus`` 的回傳語意:
4
+ None → 不歸我管(dispatcher 換下一個)
5
+ (True, msg) → 我接手且成功
6
+ (False, err) → 我接手但失敗(回報原因,不再往下試)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol
12
+
13
+ from ring.registry import Session
14
+
15
+
16
+ class Focuser(Protocol):
17
+ name: str
18
+
19
+ def try_focus(self, session: Session) -> tuple[bool, str] | None: ...
ring/focus/iterm2.py ADDED
@@ -0,0 +1,28 @@
1
+ """iTerm2 focuser(macOS):用 tty 找到對應分頁並帶到前景。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ring.focus.applescript import AppleScriptTTYFocuser
6
+
7
+ _SCRIPT = """
8
+ if application "iTerm2" is running then
9
+ tell application "iTerm2"
10
+ repeat with w in windows
11
+ repeat with t in tabs of w
12
+ repeat with s in sessions of t
13
+ if tty of s is "{tty}" then
14
+ select w
15
+ select t
16
+ select s
17
+ activate
18
+ return "ok"
19
+ end if
20
+ end repeat
21
+ end repeat
22
+ end repeat
23
+ end tell
24
+ end if
25
+ return "notfound"
26
+ """
27
+
28
+ focuser = AppleScriptTTYFocuser("iTerm2", _SCRIPT)
ring/focus/terminal.py ADDED
@@ -0,0 +1,25 @@
1
+ """Terminal.app focuser(macOS):用 tty 找到對應分頁並帶到前景。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ring.focus.applescript import AppleScriptTTYFocuser
6
+
7
+ _SCRIPT = """
8
+ if application "Terminal" is running then
9
+ tell application "Terminal"
10
+ repeat with w in windows
11
+ repeat with t in tabs of w
12
+ if tty of t is "{tty}" then
13
+ set selected tab of w to t
14
+ set frontmost of w to true
15
+ activate
16
+ return "ok"
17
+ end if
18
+ end repeat
19
+ end repeat
20
+ end tell
21
+ end if
22
+ return "notfound"
23
+ """
24
+
25
+ focuser = AppleScriptTTYFocuser("Terminal", _SCRIPT)
ring/focus/tmux.py ADDED
@@ -0,0 +1,38 @@
1
+ """tmux focuser:同一個 tmux server 直接 switch-client 切到那個 pane。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+
8
+ from ring.registry import Session
9
+
10
+
11
+ class TmuxFocuser:
12
+ name = "tmux"
13
+
14
+ def try_focus(self, session: Session) -> tuple[bool, str] | None:
15
+ target = session.tmux_target
16
+ if not target or not shutil.which("tmux"):
17
+ return None
18
+ tmux_session = target.split(":", 1)[0]
19
+ window = target.split(".", 1)[0]
20
+ ok = False
21
+ last_err = ""
22
+ for cmd in (
23
+ ["tmux", "switch-client", "-t", tmux_session],
24
+ ["tmux", "select-window", "-t", window],
25
+ ["tmux", "select-pane", "-t", target],
26
+ ):
27
+ try:
28
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
29
+ except (OSError, subprocess.SubprocessError) as exc:
30
+ return False, str(exc)
31
+ if result.returncode == 0:
32
+ ok = True
33
+ else:
34
+ last_err = result.stderr.strip()
35
+ return (True, f"tmux {target}") if ok else (False, last_err or "tmux switch failed")
36
+
37
+
38
+ focuser = TmuxFocuser()