@caddis/cli 0.2.5 → 0.3.0
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.
- package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
- package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
- package/bundles/antigravity-plugin/guard_agy.py +23 -4
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
- package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
- package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
- package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
- package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
- package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
- package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
- package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
- package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
- package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
- package/bundles/antigravity-plugin/session_end_agy.py +21 -2
- package/bundles/antigravity-plugin/skills/_registry.md +2 -0
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +22 -0
- package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +33 -0
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +213 -0
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
- package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
- package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
- package/bundles/manifest.json +3 -3
- package/dist/claude-deepseek.js +544 -0
- package/dist/claude-glm.js +544 -0
- package/dist/claude-oss.js +544 -0
- package/dist/cli.js +375 -46
- package/package.json +5 -2
- package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""caddis status line — ONE renderer for every host agent.
|
|
3
|
+
|
|
4
|
+
Replaces the four scripts that used to do this job (two bash, one PowerShell, one
|
|
5
|
+
dead): `statusline-command.sh`, `statusline-command-agy.sh`, the user's hand-made
|
|
6
|
+
`~/.claude/statusline-custom.sh`, and `~/.gemini/statusline/statusline.ps1`. Those
|
|
7
|
+
four drifted because each new segment had to be written twice in two languages.
|
|
8
|
+
|
|
9
|
+
Both hosts already spawned `python` just to parse the status JSON, so doing the
|
|
10
|
+
whole render here costs no extra process.
|
|
11
|
+
|
|
12
|
+
Usage
|
|
13
|
+
-----
|
|
14
|
+
caddis_statusline.py --profile claude # reads Claude Code status JSON on stdin
|
|
15
|
+
caddis_statusline.py --profile agy # reads agy (Antigravity) status JSON on stdin
|
|
16
|
+
caddis_statusline.py --install [--host claude|agy|both]
|
|
17
|
+
caddis_statusline.py --check
|
|
18
|
+
caddis_statusline.py --sample [--profile claude|agy]
|
|
19
|
+
|
|
20
|
+
Config
|
|
21
|
+
------
|
|
22
|
+
`~/.caddis/statusline.json` — a flat dict of toggles. Missing file = defaults.
|
|
23
|
+
See DEFAULT_CONFIG below. `--install` writes it if absent, and never overwrites it.
|
|
24
|
+
|
|
25
|
+
Payload note
|
|
26
|
+
------------
|
|
27
|
+
The two hosts send DIFFERENT field names. This is a renderer with two profiles, not
|
|
28
|
+
one schema. Claude Code field names were read off the shipped binary (v2.1.239); agy
|
|
29
|
+
field names off a live payload. Every lookup is defensive: a host that drops a field
|
|
30
|
+
loses one segment, never the line.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import shutil
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
import time
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------------------
|
|
44
|
+
# Config
|
|
45
|
+
# --------------------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
CONFIG_PATH = Path.home() / ".caddis" / "statusline.json"
|
|
48
|
+
|
|
49
|
+
DEFAULT_CONFIG = {
|
|
50
|
+
"color": True,
|
|
51
|
+
"icons": True, # Unicode segment icons (no Nerd Font required)
|
|
52
|
+
"progress_bar": True, # context bar [████░░░░]
|
|
53
|
+
"bar_width": 8,
|
|
54
|
+
"token_counts": True, # ◉ 412k/1M — off falls back to a bare ctx:41%
|
|
55
|
+
"cached_tokens": True, # ⚡38k cached — rendered LOUD, see render_claude()
|
|
56
|
+
"ram": True, # 💻 69% (44 GB)
|
|
57
|
+
"clock": True, # ◷ 03:41
|
|
58
|
+
"relay": True, # ⏺ relay (a parked resume pointer exists)
|
|
59
|
+
"rate_limits": True, # 5h:12% 7d:40%
|
|
60
|
+
"session_id": False, # #a1b2c3d4
|
|
61
|
+
"git": True,
|
|
62
|
+
"separator": "|",
|
|
63
|
+
"ram_min_width": 0, # 0 = always show; else hide below this terminal width
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_config() -> dict:
|
|
68
|
+
cfg = dict(DEFAULT_CONFIG)
|
|
69
|
+
try:
|
|
70
|
+
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
71
|
+
if isinstance(raw, dict):
|
|
72
|
+
cfg.update({k: v for k, v in raw.items() if k in DEFAULT_CONFIG})
|
|
73
|
+
except Exception:
|
|
74
|
+
pass # no config, bad JSON, unreadable — defaults are always valid
|
|
75
|
+
return cfg
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --------------------------------------------------------------------------------------
|
|
79
|
+
# Colour + icons
|
|
80
|
+
# --------------------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
class C:
|
|
83
|
+
"""ANSI escapes. Zeroed out when colour is off, so call sites need no branches."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, enabled: bool = True) -> None:
|
|
86
|
+
def e(code: str) -> str:
|
|
87
|
+
return "\033[" + code + "m" if enabled else ""
|
|
88
|
+
|
|
89
|
+
self.reset = e("0")
|
|
90
|
+
self.bold = e("1")
|
|
91
|
+
self.dim = e("90")
|
|
92
|
+
self.cyan = e("96")
|
|
93
|
+
self.yellow = e("93")
|
|
94
|
+
self.green = e("92")
|
|
95
|
+
self.magenta = e("95")
|
|
96
|
+
self.blue = e("94")
|
|
97
|
+
self.red = e("91")
|
|
98
|
+
self.white = e("97")
|
|
99
|
+
self.orange = e("38;5;208")
|
|
100
|
+
self.neon_blue = e("38;5;39")
|
|
101
|
+
self.neon_green = e("38;5;118")
|
|
102
|
+
self.gray = e("38;5;244")
|
|
103
|
+
# Context badge — reverse video, so it reads at a glance.
|
|
104
|
+
self.ctx_low = e("30;106")
|
|
105
|
+
self.ctx_med = e("30;103")
|
|
106
|
+
self.ctx_high = e("97;101")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
ICONS = {
|
|
110
|
+
"dir": "⌂", # ⌂
|
|
111
|
+
"folder": "\U0001F4C1", # 📁
|
|
112
|
+
"branch": "⎇", # ⎇
|
|
113
|
+
"model": "◆", # ◆
|
|
114
|
+
"brain": "\U0001F9E0", # 🧠
|
|
115
|
+
"ctx": "◉", # ◉
|
|
116
|
+
"clock": "◷", # ◷
|
|
117
|
+
"relay": "⏺", # ⏺
|
|
118
|
+
"ram": "\U0001F4BB", # 💻
|
|
119
|
+
"bolt": "⚡", # ⚡
|
|
120
|
+
"circle": "●", # ●
|
|
121
|
+
"bar_full": "█", # █
|
|
122
|
+
"bar_empty": "░", # ░
|
|
123
|
+
"brand_l": "▛", # ▛
|
|
124
|
+
"brand_r": "▜", # ▜
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def icon(cfg: dict, name: str) -> str:
|
|
129
|
+
"""Icon plus its trailing space, or '' when icons are off."""
|
|
130
|
+
return (ICONS[name] + " ") if cfg.get("icons", True) else ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --------------------------------------------------------------------------------------
|
|
134
|
+
# Small helpers
|
|
135
|
+
# --------------------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def dig(data, *keys):
|
|
138
|
+
"""Nested .get() that never raises. Returns '' for missing/None/False/''."""
|
|
139
|
+
cur = data
|
|
140
|
+
for k in keys:
|
|
141
|
+
if not isinstance(cur, dict):
|
|
142
|
+
return ""
|
|
143
|
+
cur = cur.get(k)
|
|
144
|
+
if cur is None or cur is False or cur == "":
|
|
145
|
+
return ""
|
|
146
|
+
return cur
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def as_int(value, default: int = 0) -> int:
|
|
150
|
+
try:
|
|
151
|
+
return int(round(float(value)))
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
return default
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def fmt_tokens(n) -> str:
|
|
157
|
+
"""48500 -> 48.5k; 1048576 -> 1M; 0 -> 0."""
|
|
158
|
+
n = as_int(n)
|
|
159
|
+
if n <= 0:
|
|
160
|
+
return "0"
|
|
161
|
+
if n >= 1_000_000:
|
|
162
|
+
v = n / 1_000_000
|
|
163
|
+
return ("%.1f" % v).rstrip("0").rstrip(".") + "M"
|
|
164
|
+
if n >= 1_000:
|
|
165
|
+
v = n / 1_000
|
|
166
|
+
return ("%.1f" % v).rstrip("0").rstrip(".") + "k"
|
|
167
|
+
return str(n)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def progress_bar(pct: float, width: int, cfg: dict) -> str:
|
|
171
|
+
pct = max(0.0, min(100.0, float(pct)))
|
|
172
|
+
filled = int(round(pct / 100.0 * width))
|
|
173
|
+
full = ICONS["bar_full"] if cfg.get("icons", True) else "#"
|
|
174
|
+
empty = ICONS["bar_empty"] if cfg.get("icons", True) else "."
|
|
175
|
+
return full * filled + empty * (width - filled)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def term_width() -> int:
|
|
179
|
+
try:
|
|
180
|
+
return shutil.get_terminal_size(fallback=(120, 24)).columns
|
|
181
|
+
except Exception:
|
|
182
|
+
return 120
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --------------------------------------------------------------------------------------
|
|
186
|
+
# Host facts the payload does not carry
|
|
187
|
+
# --------------------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def git_facts(cwd: str) -> dict:
|
|
190
|
+
"""One `git status --porcelain=v2 --branch` call -> branch, staged/unstaged/untracked,
|
|
191
|
+
ahead/behind. The v2 format gives all of it in a single invocation, which is why the
|
|
192
|
+
old bash line dropped its two-calls-plus-grep-plus-awk approach."""
|
|
193
|
+
out = {"branch": "", "staged": 0, "unstaged": 0, "untracked": 0, "ahead": 0, "behind": 0}
|
|
194
|
+
if not cwd or not os.path.isdir(cwd):
|
|
195
|
+
return out
|
|
196
|
+
try:
|
|
197
|
+
proc = subprocess.run(
|
|
198
|
+
["git", "-C", cwd, "--no-optional-locks", "status", "--porcelain=v2", "--branch"],
|
|
199
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=2.0,
|
|
200
|
+
)
|
|
201
|
+
text = proc.stdout.decode("utf-8", "replace")
|
|
202
|
+
except Exception:
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
for line in text.splitlines():
|
|
206
|
+
if line.startswith("# branch.head "):
|
|
207
|
+
head = line.split(" ", 2)[2].strip()
|
|
208
|
+
if head and head != "(detached)":
|
|
209
|
+
out["branch"] = head
|
|
210
|
+
elif line.startswith("# branch.ab "):
|
|
211
|
+
parts = line.split()
|
|
212
|
+
if len(parts) >= 4:
|
|
213
|
+
out["ahead"] = as_int(parts[2].lstrip("+"))
|
|
214
|
+
out["behind"] = as_int(parts[3].lstrip("-"))
|
|
215
|
+
elif line[:2] in ("1 ", "2 "):
|
|
216
|
+
xy = line.split(" ")[1]
|
|
217
|
+
if len(xy) >= 2:
|
|
218
|
+
if xy[0] != ".":
|
|
219
|
+
out["staged"] += 1
|
|
220
|
+
if xy[1] != ".":
|
|
221
|
+
out["unstaged"] += 1
|
|
222
|
+
elif line.startswith("u "):
|
|
223
|
+
out["unstaged"] += 1
|
|
224
|
+
elif line.startswith("? "):
|
|
225
|
+
out["untracked"] += 1
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def ram_facts():
|
|
230
|
+
"""(used_percent, used_gb) or None.
|
|
231
|
+
|
|
232
|
+
Windows goes through ctypes GlobalMemoryStatusEx — an in-process call. The old agy
|
|
233
|
+
PowerShell line used `Get-CimInstance Win32_OperatingSystem`, which costs roughly
|
|
234
|
+
300 ms and ran on EVERY status-line render.
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
if sys.platform == "win32":
|
|
238
|
+
import ctypes
|
|
239
|
+
|
|
240
|
+
class MEMORYSTATUSEX(ctypes.Structure):
|
|
241
|
+
_fields_ = [
|
|
242
|
+
("dwLength", ctypes.c_ulong),
|
|
243
|
+
("dwMemoryLoad", ctypes.c_ulong),
|
|
244
|
+
("ullTotalPhys", ctypes.c_ulonglong),
|
|
245
|
+
("ullAvailPhys", ctypes.c_ulonglong),
|
|
246
|
+
("ullTotalPageFile", ctypes.c_ulonglong),
|
|
247
|
+
("ullAvailPageFile", ctypes.c_ulonglong),
|
|
248
|
+
("ullTotalVirtual", ctypes.c_ulonglong),
|
|
249
|
+
("ullAvailVirtual", ctypes.c_ulonglong),
|
|
250
|
+
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
stat = MEMORYSTATUSEX()
|
|
254
|
+
stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
|
|
255
|
+
if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
|
|
256
|
+
return None
|
|
257
|
+
used = stat.ullTotalPhys - stat.ullAvailPhys
|
|
258
|
+
return int(stat.dwMemoryLoad), used / (1024 ** 3)
|
|
259
|
+
|
|
260
|
+
if sys.platform.startswith("linux"):
|
|
261
|
+
total = avail = 0
|
|
262
|
+
with open("/proc/meminfo", "r") as fh:
|
|
263
|
+
for line in fh:
|
|
264
|
+
if line.startswith("MemTotal:"):
|
|
265
|
+
total = int(line.split()[1]) * 1024
|
|
266
|
+
elif line.startswith("MemAvailable:"):
|
|
267
|
+
avail = int(line.split()[1]) * 1024
|
|
268
|
+
if total and avail:
|
|
269
|
+
break
|
|
270
|
+
if not total:
|
|
271
|
+
return None
|
|
272
|
+
used = total - avail
|
|
273
|
+
return int(round(used / total * 100)), used / (1024 ** 3)
|
|
274
|
+
|
|
275
|
+
if sys.platform == "darwin":
|
|
276
|
+
total = int(subprocess.run(["sysctl", "-n", "hw.memsize"], stdout=subprocess.PIPE,
|
|
277
|
+
timeout=1.0).stdout.strip())
|
|
278
|
+
vm = subprocess.run(["vm_stat"], stdout=subprocess.PIPE, timeout=1.0)
|
|
279
|
+
page = 4096
|
|
280
|
+
free_pages = 0
|
|
281
|
+
for line in vm.stdout.decode("utf-8", "replace").splitlines():
|
|
282
|
+
if "page size of" in line:
|
|
283
|
+
page = int(line.split("page size of")[1].split()[0])
|
|
284
|
+
for label in ("Pages free:", "Pages inactive:", "Pages speculative:"):
|
|
285
|
+
if line.startswith(label):
|
|
286
|
+
free_pages += int(line.split(":")[1].strip().rstrip("."))
|
|
287
|
+
avail = free_pages * page
|
|
288
|
+
used = total - avail
|
|
289
|
+
return int(round(used / total * 100)), used / (1024 ** 3)
|
|
290
|
+
except Exception:
|
|
291
|
+
return None
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def relay_present(cwd: str) -> bool:
|
|
296
|
+
"""A parked resume pointer exists for this repo. `.caddis/relay.md` is canonical;
|
|
297
|
+
a bare `relay.md` at the root is the pre-rename legacy location."""
|
|
298
|
+
if not cwd:
|
|
299
|
+
return False
|
|
300
|
+
return os.path.isfile(os.path.join(cwd, ".caddis", "relay.md")) or \
|
|
301
|
+
os.path.isfile(os.path.join(cwd, "relay.md"))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def ctx_color(c: C, pct: float) -> str:
|
|
305
|
+
if pct >= 80:
|
|
306
|
+
return c.ctx_high
|
|
307
|
+
if pct >= 60:
|
|
308
|
+
return c.ctx_med
|
|
309
|
+
return c.ctx_low
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# --------------------------------------------------------------------------------------
|
|
313
|
+
# Shared segments
|
|
314
|
+
# --------------------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def git_segment(c: C, cfg: dict, git: dict, repo: str, worktree: str, base_color: str) -> str:
|
|
317
|
+
"""`saajunaid/caddis ⎇ main +2 ●1 ?3 ↑1 ↓2` — the counts split by stage, which the
|
|
318
|
+
stock single ±N count could not tell apart."""
|
|
319
|
+
body = repo or ""
|
|
320
|
+
if git["branch"]:
|
|
321
|
+
body = (body + " " if body else "") + icon(cfg, "branch") + git["branch"]
|
|
322
|
+
if not body:
|
|
323
|
+
return ""
|
|
324
|
+
if worktree:
|
|
325
|
+
body += "[" + worktree + "]"
|
|
326
|
+
if git["staged"]:
|
|
327
|
+
body += " " + c.green + "+" + str(git["staged"]) + base_color
|
|
328
|
+
if git["unstaged"]:
|
|
329
|
+
body += " " + c.red + ICONS["circle"] + str(git["unstaged"]) + base_color
|
|
330
|
+
if git["untracked"]:
|
|
331
|
+
body += " " + c.blue + "?" + str(git["untracked"]) + base_color
|
|
332
|
+
if git["ahead"]:
|
|
333
|
+
body += " " + c.green + "↑" + str(git["ahead"]) + base_color
|
|
334
|
+
if git["behind"]:
|
|
335
|
+
body += " " + c.red + "↓" + str(git["behind"]) + base_color
|
|
336
|
+
return base_color + body + c.reset
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def context_segment(c: C, cfg: dict, used_tokens: int, max_tokens: int, pct: float) -> str:
|
|
340
|
+
"""`◉ 412k/1M [████░░░░] 41%` — the token counts and the bar are what Example 2 had
|
|
341
|
+
and the Claude line did not."""
|
|
342
|
+
parts = []
|
|
343
|
+
label = icon(cfg, "ctx") if cfg.get("icons", True) else "ctx "
|
|
344
|
+
color = ctx_color(c, pct)
|
|
345
|
+
if cfg.get("token_counts", True) and max_tokens > 0:
|
|
346
|
+
parts.append(c.white + c.bold + fmt_tokens(used_tokens) + c.reset +
|
|
347
|
+
c.dim + "/" + fmt_tokens(max_tokens) + c.reset)
|
|
348
|
+
if cfg.get("progress_bar", True):
|
|
349
|
+
parts.append("[" + color + progress_bar(pct, as_int(cfg.get("bar_width"), 8), cfg) + c.reset + "]")
|
|
350
|
+
parts.append(color + " %d%% " % as_int(pct) + c.reset)
|
|
351
|
+
return label + " ".join(parts)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def cached_segment(c: C, cfg: dict, cached: int) -> str:
|
|
355
|
+
"""LOUD by design. The old PowerShell line hid this behind `termWidth >= 130` and
|
|
356
|
+
dimmed it, so the one number that tells you the prompt cache is working was the
|
|
357
|
+
first thing to disappear. Here it is bold, bright, and never width-gated."""
|
|
358
|
+
if not cfg.get("cached_tokens", True) or cached <= 0:
|
|
359
|
+
return ""
|
|
360
|
+
return c.bold + c.cyan + icon(cfg, "bolt") + fmt_tokens(cached) + " cached" + c.reset
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def ram_segment(c: C, cfg: dict) -> str:
|
|
364
|
+
if not cfg.get("ram", True):
|
|
365
|
+
return ""
|
|
366
|
+
min_w = as_int(cfg.get("ram_min_width"), 0)
|
|
367
|
+
if min_w and term_width() < min_w:
|
|
368
|
+
return ""
|
|
369
|
+
facts = ram_facts()
|
|
370
|
+
if not facts:
|
|
371
|
+
return ""
|
|
372
|
+
pct, gb = facts
|
|
373
|
+
col = c.orange if pct > 85 else (c.yellow if pct > 70 else c.white)
|
|
374
|
+
return icon(cfg, "ram") + col + "%d%%" % pct + c.reset + c.dim + " (%.0f GB)" % gb + c.reset
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def clock_segment(c: C, cfg: dict) -> str:
|
|
378
|
+
if not cfg.get("clock", True):
|
|
379
|
+
return ""
|
|
380
|
+
return c.dim + icon(cfg, "clock") + time.strftime("%H:%M") + c.reset
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def join(c: C, cfg: dict, parts) -> str:
|
|
384
|
+
sep = " " + c.dim + str(cfg.get("separator", "|")) + c.reset + " "
|
|
385
|
+
return sep.join(p for p in parts if p)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# --------------------------------------------------------------------------------------
|
|
389
|
+
# Profile: Claude Code
|
|
390
|
+
# --------------------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
def render_claude(data: dict, cfg: dict) -> str:
|
|
393
|
+
c = C(cfg.get("color", True))
|
|
394
|
+
cwd = dig(data, "cwd") or dig(data, "workspace", "current_dir")
|
|
395
|
+
owner = dig(data, "workspace", "repo", "owner")
|
|
396
|
+
name = dig(data, "workspace", "repo", "name")
|
|
397
|
+
repo = ("%s/%s" % (owner, name)) if owner and name else ""
|
|
398
|
+
worktree = dig(data, "workspace", "git_worktree")
|
|
399
|
+
|
|
400
|
+
git = {"branch": dig(data, "worktree", "branch") or "", "staged": 0, "unstaged": 0,
|
|
401
|
+
"untracked": 0, "ahead": 0, "behind": 0}
|
|
402
|
+
if cfg.get("git", True):
|
|
403
|
+
found = git_facts(cwd)
|
|
404
|
+
found["branch"] = git["branch"] or found["branch"]
|
|
405
|
+
git = found
|
|
406
|
+
|
|
407
|
+
parts = []
|
|
408
|
+
|
|
409
|
+
if cwd:
|
|
410
|
+
parts.append(c.cyan + icon(cfg, "dir") + os.path.basename(cwd.rstrip("/\\")) + c.reset)
|
|
411
|
+
|
|
412
|
+
seg = git_segment(c, cfg, git, repo, worktree, c.yellow)
|
|
413
|
+
if seg:
|
|
414
|
+
parts.append(seg)
|
|
415
|
+
|
|
416
|
+
model = dig(data, "model", "display_name")
|
|
417
|
+
if model:
|
|
418
|
+
version = dig(data, "version")
|
|
419
|
+
parts.append(c.green + icon(cfg, "model") + model + (" v" + str(version) if version else "") + c.reset)
|
|
420
|
+
|
|
421
|
+
cw = data.get("context_window") if isinstance(data.get("context_window"), dict) else {}
|
|
422
|
+
if cw:
|
|
423
|
+
usage = cw.get("current_usage") if isinstance(cw.get("current_usage"), dict) else {}
|
|
424
|
+
cached = as_int(usage.get("cache_read_input_tokens"))
|
|
425
|
+
used = (as_int(usage.get("input_tokens")) + cached +
|
|
426
|
+
as_int(usage.get("cache_creation_input_tokens")))
|
|
427
|
+
if used == 0:
|
|
428
|
+
used = as_int(cw.get("total_input_tokens"))
|
|
429
|
+
max_tokens = as_int(cw.get("context_window_size"))
|
|
430
|
+
pct = float(as_int(cw.get("used_percentage")))
|
|
431
|
+
parts.append(context_segment(c, cfg, used, max_tokens, pct))
|
|
432
|
+
loud = cached_segment(c, cfg, cached)
|
|
433
|
+
if loud:
|
|
434
|
+
parts.append(loud)
|
|
435
|
+
|
|
436
|
+
effort = dig(data, "effort", "level")
|
|
437
|
+
if effort:
|
|
438
|
+
parts.append(c.blue + "effort:" + str(effort) + c.reset)
|
|
439
|
+
|
|
440
|
+
style = dig(data, "output_style", "name")
|
|
441
|
+
if style and str(style).lower() != "default":
|
|
442
|
+
parts.append(c.blue + "style:" + str(style) + c.reset)
|
|
443
|
+
|
|
444
|
+
if dig(data, "thinking", "enabled") is True:
|
|
445
|
+
parts.append(c.magenta + "thinking" + c.reset)
|
|
446
|
+
|
|
447
|
+
if cfg.get("rate_limits", True):
|
|
448
|
+
five = dig(data, "rate_limits", "five_hour", "used_percentage")
|
|
449
|
+
seven = dig(data, "rate_limits", "seven_day", "used_percentage")
|
|
450
|
+
bits = []
|
|
451
|
+
if five != "":
|
|
452
|
+
bits.append("5h:%d%%" % as_int(five))
|
|
453
|
+
if seven != "":
|
|
454
|
+
bits.append("7d:%d%%" % as_int(seven))
|
|
455
|
+
if bits:
|
|
456
|
+
parts.append(c.yellow + " ".join(bits) + c.reset)
|
|
457
|
+
|
|
458
|
+
pr_num = dig(data, "pr", "number")
|
|
459
|
+
if pr_num:
|
|
460
|
+
state = dig(data, "pr", "review_state")
|
|
461
|
+
parts.append(c.green + "PR#" + str(pr_num) + (("(" + str(state) + ")") if state else "") + c.reset)
|
|
462
|
+
|
|
463
|
+
vim = dig(data, "vim", "mode")
|
|
464
|
+
if vim:
|
|
465
|
+
parts.append(c.white + "[" + str(vim) + "]" + c.reset)
|
|
466
|
+
|
|
467
|
+
ram = ram_segment(c, cfg)
|
|
468
|
+
if ram:
|
|
469
|
+
parts.append(ram)
|
|
470
|
+
|
|
471
|
+
if cfg.get("relay", True) and relay_present(cwd):
|
|
472
|
+
parts.append(c.dim + icon(cfg, "relay") + "relay" + c.reset)
|
|
473
|
+
|
|
474
|
+
session_name = dig(data, "session_name")
|
|
475
|
+
if session_name:
|
|
476
|
+
parts.append(c.dim + '"' + str(session_name) + '"' + c.reset)
|
|
477
|
+
|
|
478
|
+
if cfg.get("session_id", False):
|
|
479
|
+
sid = str(dig(data, "session_id") or "")
|
|
480
|
+
if sid:
|
|
481
|
+
parts.append(c.dim + "#" + sid[:8] + c.reset)
|
|
482
|
+
|
|
483
|
+
clock = clock_segment(c, cfg)
|
|
484
|
+
if clock:
|
|
485
|
+
parts.append(clock)
|
|
486
|
+
|
|
487
|
+
return join(c, cfg, parts)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# --------------------------------------------------------------------------------------
|
|
491
|
+
# Profile: agy (Antigravity)
|
|
492
|
+
# --------------------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
def render_agy(data: dict, cfg: dict) -> str:
|
|
495
|
+
"""agy sends different field names to Claude Code. This is a sibling, not a copy.
|
|
496
|
+
Layout follows the agy line the user already had; the git detail, quota, relay and
|
|
497
|
+
clock come across from the Claude line."""
|
|
498
|
+
c = C(cfg.get("color", True))
|
|
499
|
+
cwd = dig(data, "cwd") or dig(data, "workspace", "current_dir")
|
|
500
|
+
|
|
501
|
+
parts = []
|
|
502
|
+
|
|
503
|
+
# Brand mark, so an agy pane is never mistaken for a Claude Code pane.
|
|
504
|
+
if cfg.get("icons", True):
|
|
505
|
+
parts.append(c.neon_blue + ICONS["brand_l"] + c.neon_green + ICONS["brand_r"] +
|
|
506
|
+
" " + c.white + c.bold + "agy" + c.reset)
|
|
507
|
+
else:
|
|
508
|
+
parts.append(c.white + c.bold + "agy" + c.reset)
|
|
509
|
+
|
|
510
|
+
state = str(dig(data, "agent_state") or dig(data, "execution_mode") or "").lower()
|
|
511
|
+
if state:
|
|
512
|
+
label, col = {
|
|
513
|
+
"working": (icon(cfg, "bolt") + "working", c.green),
|
|
514
|
+
"thinking": (icon(cfg, "brain") + "thinking", c.cyan),
|
|
515
|
+
"idle": (icon(cfg, "circle") + "idle", c.gray),
|
|
516
|
+
}.get(state, (state, c.gray))
|
|
517
|
+
parts.append(col + label + c.reset)
|
|
518
|
+
|
|
519
|
+
model = dig(data, "model", "display_name") or dig(data, "model", "id")
|
|
520
|
+
if model:
|
|
521
|
+
effort = dig(data, "model", "effort")
|
|
522
|
+
parts.append(c.green + icon(cfg, "brain") + str(model) +
|
|
523
|
+
((" (" + str(effort) + ")") if effort else "") + c.reset)
|
|
524
|
+
|
|
525
|
+
git = {"branch": str(dig(data, "vcs", "branch") or ""), "staged": 0, "unstaged": 0,
|
|
526
|
+
"untracked": 0, "ahead": 0, "behind": 0}
|
|
527
|
+
if cfg.get("git", True):
|
|
528
|
+
found = git_facts(cwd)
|
|
529
|
+
found["branch"] = git["branch"] or found["branch"]
|
|
530
|
+
git = found
|
|
531
|
+
|
|
532
|
+
if cwd:
|
|
533
|
+
parts.append(c.neon_blue + icon(cfg, "folder") + os.path.basename(cwd.rstrip("/\\")) + c.reset)
|
|
534
|
+
seg = git_segment(c, cfg, git, "", "", c.yellow)
|
|
535
|
+
if seg:
|
|
536
|
+
parts.append(seg)
|
|
537
|
+
|
|
538
|
+
cw = data.get("context_window") if isinstance(data.get("context_window"), dict) else {}
|
|
539
|
+
if cw:
|
|
540
|
+
usage = cw.get("current_usage") if isinstance(cw.get("current_usage"), dict) else {}
|
|
541
|
+
cached = as_int(usage.get("cache_read_input_tokens"))
|
|
542
|
+
used = (as_int(usage.get("input_tokens")) + cached +
|
|
543
|
+
as_int(usage.get("cache_creation_input_tokens")))
|
|
544
|
+
if used == 0:
|
|
545
|
+
used = as_int(cw.get("total_input_tokens"))
|
|
546
|
+
max_tokens = as_int(cw.get("context_window_size")) or 1_048_576
|
|
547
|
+
pct = float(as_int(cw.get("used_percentage")))
|
|
548
|
+
parts.append(context_segment(c, cfg, used, max_tokens, pct))
|
|
549
|
+
loud = cached_segment(c, cfg, cached)
|
|
550
|
+
if loud:
|
|
551
|
+
parts.append(loud)
|
|
552
|
+
|
|
553
|
+
# Quota: agy sends {pool_name: {remaining_fraction, ...}}. Show the BINDING pool —
|
|
554
|
+
# the one with least left — because that is the one that will stop the session.
|
|
555
|
+
quota = data.get("quota")
|
|
556
|
+
if isinstance(quota, dict) and quota:
|
|
557
|
+
fractions = [v.get("remaining_fraction") for v in quota.values()
|
|
558
|
+
if isinstance(v, dict) and isinstance(v.get("remaining_fraction"), (int, float))]
|
|
559
|
+
if fractions:
|
|
560
|
+
q = int(round(min(fractions) * 100))
|
|
561
|
+
col = c.red if q <= 15 else (c.yellow if q <= 40 else c.green)
|
|
562
|
+
parts.append(col + "q " + str(q) + "%" + c.reset)
|
|
563
|
+
|
|
564
|
+
ram = ram_segment(c, cfg)
|
|
565
|
+
if ram:
|
|
566
|
+
parts.append(ram)
|
|
567
|
+
|
|
568
|
+
if cfg.get("relay", True) and relay_present(cwd):
|
|
569
|
+
parts.append(c.dim + icon(cfg, "relay") + "relay" + c.reset)
|
|
570
|
+
|
|
571
|
+
if cfg.get("session_id", False):
|
|
572
|
+
sid = str(dig(data, "session_id") or "")
|
|
573
|
+
if sid:
|
|
574
|
+
parts.append(c.dim + "#" + sid[:8] + c.reset)
|
|
575
|
+
|
|
576
|
+
clock = clock_segment(c, cfg)
|
|
577
|
+
if clock:
|
|
578
|
+
parts.append(clock)
|
|
579
|
+
|
|
580
|
+
return join(c, cfg, parts)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
RENDERERS = {"claude": render_claude, "agy": render_agy}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# --------------------------------------------------------------------------------------
|
|
587
|
+
# Install / check
|
|
588
|
+
# --------------------------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
INSTALL_DIR = Path.home() / ".caddis"
|
|
591
|
+
INSTALLED_SCRIPT = INSTALL_DIR / "statusline.py"
|
|
592
|
+
|
|
593
|
+
HOST_SETTINGS = {
|
|
594
|
+
"claude": Path.home() / ".claude" / "settings.json",
|
|
595
|
+
"agy": Path.home() / ".gemini" / "antigravity-cli" / "settings.json",
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def python_command() -> str:
|
|
600
|
+
"""The interpreter NAME to write into settings — not an absolute path.
|
|
601
|
+
|
|
602
|
+
Both hosts run the status-line command through a shell that inherits PATH, so a
|
|
603
|
+
name survives a Python upgrade that moves the install directory. An absolute
|
|
604
|
+
venv path would not.
|
|
605
|
+
"""
|
|
606
|
+
for candidate in ("python", "python3"):
|
|
607
|
+
if shutil.which(candidate):
|
|
608
|
+
return candidate
|
|
609
|
+
return "python"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def install(hosts, dry_run: bool = False) -> int:
|
|
613
|
+
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
|
|
614
|
+
src = Path(__file__).resolve()
|
|
615
|
+
|
|
616
|
+
# Copy the renderer to a STABLE path. Pointing settings at the plugin cache
|
|
617
|
+
# (~/.claude/plugins/cache/caddis/caddis/<version>/) breaks on every upgrade,
|
|
618
|
+
# because the version is in the path.
|
|
619
|
+
if not dry_run and src != INSTALLED_SCRIPT.resolve():
|
|
620
|
+
shutil.copyfile(str(src), str(INSTALLED_SCRIPT))
|
|
621
|
+
print("renderer -> %s" % INSTALLED_SCRIPT)
|
|
622
|
+
|
|
623
|
+
if not CONFIG_PATH.exists():
|
|
624
|
+
if not dry_run:
|
|
625
|
+
CONFIG_PATH.write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n", encoding="utf-8")
|
|
626
|
+
print("config -> %s (created)" % CONFIG_PATH)
|
|
627
|
+
else:
|
|
628
|
+
print("config -> %s (kept, not overwritten)" % CONFIG_PATH)
|
|
629
|
+
|
|
630
|
+
py = python_command()
|
|
631
|
+
failures = 0
|
|
632
|
+
for host in hosts:
|
|
633
|
+
settings = HOST_SETTINGS[host]
|
|
634
|
+
# agy runs its status-line command through cmd.exe on Windows, where a quoted
|
|
635
|
+
# path containing spaces breaks. Home is space-free here; keep it native.
|
|
636
|
+
command = '%s "%s" --profile %s' % (py, str(INSTALLED_SCRIPT), host)
|
|
637
|
+
try:
|
|
638
|
+
failures += wire_host(host, settings, command, dry_run)
|
|
639
|
+
except Exception as exc: # never leave a half-written settings file unexplained
|
|
640
|
+
print(" !! %s: %s" % (host, exc))
|
|
641
|
+
failures += 1
|
|
642
|
+
return failures
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def wire_host(host: str, settings_path: Path, command: str, dry_run: bool) -> int:
|
|
646
|
+
if not settings_path.parent.exists():
|
|
647
|
+
print(" -- %-6s not installed (%s missing) — skipped" % (host, settings_path.parent))
|
|
648
|
+
return 0
|
|
649
|
+
|
|
650
|
+
data = {}
|
|
651
|
+
if settings_path.exists():
|
|
652
|
+
try:
|
|
653
|
+
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
654
|
+
except Exception as exc:
|
|
655
|
+
print(" !! %s: %s is not valid JSON (%s) — refusing to write" % (host, settings_path, exc))
|
|
656
|
+
return 1
|
|
657
|
+
if not isinstance(data, dict):
|
|
658
|
+
print(" !! %s: %s is not a JSON object — refusing to write" % (host, settings_path))
|
|
659
|
+
return 1
|
|
660
|
+
|
|
661
|
+
entry = {"type": "command", "command": command}
|
|
662
|
+
if host == "agy":
|
|
663
|
+
entry["enabled"] = True # agy ignores a statusLine block without this
|
|
664
|
+
|
|
665
|
+
if data.get("statusLine") == entry:
|
|
666
|
+
print(" ok %-6s already wired" % host)
|
|
667
|
+
return 0
|
|
668
|
+
|
|
669
|
+
if not dry_run:
|
|
670
|
+
if settings_path.exists():
|
|
671
|
+
backup = settings_path.with_suffix(settings_path.suffix + ".bak-caddis-statusline")
|
|
672
|
+
shutil.copyfile(str(settings_path), str(backup))
|
|
673
|
+
data["statusLine"] = entry
|
|
674
|
+
settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
675
|
+
print(" ok %-6s wired -> %s" % (host, settings_path))
|
|
676
|
+
return 0
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def check() -> int:
|
|
680
|
+
print("renderer: %s %s" % (INSTALLED_SCRIPT, "(present)" if INSTALLED_SCRIPT.exists() else "(MISSING)"))
|
|
681
|
+
print("config: %s %s" % (CONFIG_PATH, "(present)" if CONFIG_PATH.exists() else "(defaults)"))
|
|
682
|
+
bad = 0
|
|
683
|
+
for host, settings_path in HOST_SETTINGS.items():
|
|
684
|
+
if not settings_path.exists():
|
|
685
|
+
print(" -- %-6s %s (not installed)" % (host, settings_path))
|
|
686
|
+
continue
|
|
687
|
+
try:
|
|
688
|
+
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
689
|
+
except Exception:
|
|
690
|
+
print(" !! %-6s %s is not valid JSON" % (host, settings_path))
|
|
691
|
+
bad += 1
|
|
692
|
+
continue
|
|
693
|
+
entry = data.get("statusLine")
|
|
694
|
+
if not isinstance(entry, dict):
|
|
695
|
+
print(" !! %-6s no statusLine configured" % host)
|
|
696
|
+
bad += 1
|
|
697
|
+
continue
|
|
698
|
+
cmd = str(entry.get("command", ""))
|
|
699
|
+
if "statusline.py" in cmd and "--profile " + host in cmd:
|
|
700
|
+
print(" ok %-6s %s" % (host, cmd))
|
|
701
|
+
else:
|
|
702
|
+
print(" !! %-6s points elsewhere: %s" % (host, cmd))
|
|
703
|
+
bad += 1
|
|
704
|
+
return bad
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
SAMPLES = {
|
|
708
|
+
"claude": {
|
|
709
|
+
"cwd": os.getcwd(),
|
|
710
|
+
"session_id": "a1b2c3d4-0000-0000-0000-000000000000",
|
|
711
|
+
"version": "2.1.239",
|
|
712
|
+
"model": {"display_name": "Opus 5 (1M context)"},
|
|
713
|
+
"workspace": {"current_dir": os.getcwd(), "repo": {"owner": "saajunaid", "name": "caddis"}},
|
|
714
|
+
"output_style": {"name": "Plain"},
|
|
715
|
+
"effort": {"level": "high"},
|
|
716
|
+
"thinking": {"enabled": True},
|
|
717
|
+
"context_window": {
|
|
718
|
+
"context_window_size": 1000000,
|
|
719
|
+
"total_input_tokens": 412000,
|
|
720
|
+
"current_usage": {"input_tokens": 12000, "cache_read_input_tokens": 386000,
|
|
721
|
+
"cache_creation_input_tokens": 14000, "output_tokens": 3100},
|
|
722
|
+
"used_percentage": 41.2,
|
|
723
|
+
},
|
|
724
|
+
"rate_limits": {"five_hour": {"used_percentage": 12}, "seven_day": {"used_percentage": 40}},
|
|
725
|
+
},
|
|
726
|
+
"agy": {
|
|
727
|
+
"cwd": os.getcwd(),
|
|
728
|
+
"session_id": "9f8e7d6c-0000-0000-0000-000000000000",
|
|
729
|
+
"agent_state": "idle",
|
|
730
|
+
"model": {"display_name": "Gemini 3.6 Flash (High)", "effort": "high"},
|
|
731
|
+
"workspace": {"current_dir": os.getcwd()},
|
|
732
|
+
"context_window": {
|
|
733
|
+
"context_window_size": 1048576,
|
|
734
|
+
"total_input_tokens": 48500,
|
|
735
|
+
"current_usage": {"input_tokens": 8500, "cache_read_input_tokens": 40000},
|
|
736
|
+
"used_percentage": 4.6,
|
|
737
|
+
},
|
|
738
|
+
"quota": {"gemini": {"remaining_fraction": 0.87}, "claude_gpt": {"remaining_fraction": 0.62}},
|
|
739
|
+
},
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# --------------------------------------------------------------------------------------
|
|
744
|
+
# Entry point
|
|
745
|
+
# --------------------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
def main(argv=None) -> int:
|
|
748
|
+
parser = argparse.ArgumentParser(description="caddis status line — one renderer, every host.")
|
|
749
|
+
parser.add_argument("--profile", choices=sorted(RENDERERS), default="claude",
|
|
750
|
+
help="which host's status JSON to expect on stdin")
|
|
751
|
+
parser.add_argument("--install", action="store_true", help="wire the status line into the host settings")
|
|
752
|
+
parser.add_argument("--host", choices=["claude", "agy", "both"], default="both",
|
|
753
|
+
help="with --install: which host(s) to wire")
|
|
754
|
+
parser.add_argument("--check", action="store_true", help="report what is wired, change nothing")
|
|
755
|
+
parser.add_argument("--sample", action="store_true", help="render a fake payload (preview / test)")
|
|
756
|
+
parser.add_argument("--dry-run", action="store_true", help="with --install: print, write nothing")
|
|
757
|
+
parser.add_argument("--no-color", action="store_true", help="render without ANSI colour")
|
|
758
|
+
args = parser.parse_args(argv)
|
|
759
|
+
|
|
760
|
+
# The line is Unicode. On Windows the default cp1252 stdout raises on ◆/⎇/█.
|
|
761
|
+
try:
|
|
762
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
763
|
+
except Exception:
|
|
764
|
+
pass
|
|
765
|
+
|
|
766
|
+
if args.install:
|
|
767
|
+
hosts = ["claude", "agy"] if args.host == "both" else [args.host]
|
|
768
|
+
return 1 if install(hosts, args.dry_run) else 0
|
|
769
|
+
|
|
770
|
+
if args.check:
|
|
771
|
+
return 1 if check() else 0
|
|
772
|
+
|
|
773
|
+
cfg = load_config()
|
|
774
|
+
if args.no_color:
|
|
775
|
+
cfg["color"] = False
|
|
776
|
+
|
|
777
|
+
if args.sample:
|
|
778
|
+
data = SAMPLES[args.profile]
|
|
779
|
+
else:
|
|
780
|
+
try:
|
|
781
|
+
raw = sys.stdin.read()
|
|
782
|
+
except Exception:
|
|
783
|
+
raw = ""
|
|
784
|
+
start = raw.find("{") # strip a UTF-8 BOM or any leading noise
|
|
785
|
+
try:
|
|
786
|
+
data = json.loads(raw[start:]) if start >= 0 else {}
|
|
787
|
+
except Exception:
|
|
788
|
+
data = {}
|
|
789
|
+
if not isinstance(data, dict):
|
|
790
|
+
data = {}
|
|
791
|
+
|
|
792
|
+
try:
|
|
793
|
+
line = RENDERERS[args.profile](data, cfg)
|
|
794
|
+
except Exception:
|
|
795
|
+
# A status line must never break the host. Degrade to the directory name.
|
|
796
|
+
line = os.path.basename(os.getcwd())
|
|
797
|
+
|
|
798
|
+
print(line)
|
|
799
|
+
return 0
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
if __name__ == "__main__":
|
|
803
|
+
sys.exit(main())
|