@caddis/cli 0.2.6 → 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.
Files changed (51) hide show
  1. package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
  2. package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
  3. package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
  4. package/bundles/antigravity-plugin/guard_agy.py +23 -4
  5. package/bundles/antigravity-plugin/plugin.json +1 -1
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
  12. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
  13. package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
  14. package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
  15. package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
  16. package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
  17. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
  18. package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
  19. package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
  21. package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
  22. package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
  23. package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
  24. package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
  25. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
  26. package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
  27. package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
  28. package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
  29. package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
  30. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
  31. package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
  32. package/bundles/antigravity-plugin/session_end_agy.py +21 -2
  33. package/bundles/antigravity-plugin/skills/_registry.md +1 -0
  34. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
  35. package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  37. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
  39. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
  40. package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
  41. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  42. package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
  43. package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
  45. package/bundles/manifest.json +3 -3
  46. package/dist/claude-deepseek.js +544 -0
  47. package/dist/claude-glm.js +544 -0
  48. package/dist/claude-oss.js +544 -0
  49. package/dist/cli.js +365 -44
  50. package/package.json +5 -2
  51. 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())