codebyplan 1.10.3 → 1.11.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/dist/cli.js +534 -209
- package/package.json +1 -1
- package/templates/hooks/README.md +58 -16
- package/templates/hooks/cbp-statusline.mjs +385 -0
- package/templates/hooks/cbp-statusline.py +331 -0
- package/templates/hooks/cbp-statusline.sh +138 -82
- package/templates/hooks/cbp-subagent-statusline.mjs +200 -0
- package/templates/hooks/cbp-subagent-statusline.py +183 -0
- package/templates/hooks/cbp-subagent-statusline.sh +87 -39
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# @hook: NOT-A-HOOK (statusLine renderer, invoked via the cbp-statusline.sh dispatcher)
|
|
3
|
+
# Claude Code Status Line — python renderer.
|
|
4
|
+
# Byte-identical output to the bash renderer in cbp-statusline.sh and the node
|
|
5
|
+
# renderer in cbp-statusline.mjs. See that .sh file for the full option/env/seam
|
|
6
|
+
# contract. Selected via .codebyplan/statusline.local.json -> {"renderer":"python"}.
|
|
7
|
+
#
|
|
8
|
+
# Reads stdin JSON; resolves .codebyplan/ root from CBP_STATUSLINE_ROOT (set by the
|
|
9
|
+
# dispatcher) or argv[1], else the script's ../.. directory. Never throws to stdout.
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get(obj, keys, dflt):
|
|
20
|
+
# jq `//` semantics: default on null/missing (and false → false).
|
|
21
|
+
cur = obj
|
|
22
|
+
for k in keys:
|
|
23
|
+
if not isinstance(cur, dict):
|
|
24
|
+
return dflt
|
|
25
|
+
cur = cur.get(k)
|
|
26
|
+
return dflt if cur is None else cur
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main():
|
|
30
|
+
raw_input = sys.stdin.read()
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(raw_input)
|
|
33
|
+
except Exception:
|
|
34
|
+
data = {}
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
data = {}
|
|
37
|
+
|
|
38
|
+
# ---- Root resolution (matches the dispatcher contract) -------------------
|
|
39
|
+
root = os.environ.get("CBP_STATUSLINE_ROOT") or (sys.argv[1] if len(sys.argv) > 1 else "")
|
|
40
|
+
if not root:
|
|
41
|
+
root = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
|
|
42
|
+
|
|
43
|
+
MODEL_ID = _get(data, ["model", "id"], "")
|
|
44
|
+
MODEL_NAME = _get(data, ["model", "display_name"], "")
|
|
45
|
+
CWD = _get(data, ["cwd"], "")
|
|
46
|
+
WS_CURRENT_DIR = _get(data, ["workspace", "current_dir"], "")
|
|
47
|
+
WS_REPO_HOST = _get(data, ["workspace", "repo", "host"], "")
|
|
48
|
+
WS_REPO_OWNER = _get(data, ["workspace", "repo", "owner"], "")
|
|
49
|
+
WS_REPO_NAME = _get(data, ["workspace", "repo", "name"], "")
|
|
50
|
+
COST = _get(data, ["cost", "total_cost_usd"], 0)
|
|
51
|
+
DURATION = _get(data, ["cost", "total_duration_ms"], 0)
|
|
52
|
+
API_DURATION = _get(data, ["cost", "total_api_duration_ms"], 0)
|
|
53
|
+
LINES_ADD = _get(data, ["cost", "total_lines_added"], 0)
|
|
54
|
+
LINES_DEL = _get(data, ["cost", "total_lines_removed"], 0)
|
|
55
|
+
CTX_SIZE = _get(data, ["context_window", "context_window_size"], 200000)
|
|
56
|
+
CTX_PCT = _get(data, ["context_window", "used_percentage"], 0)
|
|
57
|
+
CUR_IN = _get(data, ["context_window", "current_usage", "input_tokens"], 0)
|
|
58
|
+
CUR_OUT = _get(data, ["context_window", "current_usage", "output_tokens"], 0)
|
|
59
|
+
CACHE_CREATE = _get(data, ["context_window", "current_usage", "cache_creation_input_tokens"], 0)
|
|
60
|
+
CACHE_READ = _get(data, ["context_window", "current_usage", "cache_read_input_tokens"], 0)
|
|
61
|
+
EXCEEDS_200K = _get(data, ["exceeds_200k_tokens"], False)
|
|
62
|
+
EFFORT = _get(data, ["effort", "level"], "")
|
|
63
|
+
THINKING = _get(data, ["thinking", "enabled"], False)
|
|
64
|
+
RATE_5H_PCT = _get(data, ["rate_limits", "five_hour", "used_percentage"], "")
|
|
65
|
+
RATE_5H_RESETS = _get(data, ["rate_limits", "five_hour", "resets_at"], 0)
|
|
66
|
+
RATE_7D_PCT = _get(data, ["rate_limits", "seven_day", "used_percentage"], "")
|
|
67
|
+
RATE_7D_RESETS = _get(data, ["rate_limits", "seven_day", "resets_at"], 0)
|
|
68
|
+
SESSION_NAME = _get(data, ["session_name"], "")
|
|
69
|
+
OUTPUT_STYLE = _get(data, ["output_style", "name"], "")
|
|
70
|
+
VIM_MODE = _get(data, ["vim", "mode"], "")
|
|
71
|
+
AGENT_NAME = _get(data, ["agent", "name"], "")
|
|
72
|
+
PR_NUMBER = _get(data, ["pr", "number"], "")
|
|
73
|
+
PR_URL = _get(data, ["pr", "url"], "")
|
|
74
|
+
PR_REVIEW_STATE = _get(data, ["pr", "review_state"], "")
|
|
75
|
+
WT_NAME = _get(data, ["worktree", "name"], "")
|
|
76
|
+
WT_PATH = _get(data, ["worktree", "path"], "")
|
|
77
|
+
WT_BRANCH = _get(data, ["worktree", "branch"], "")
|
|
78
|
+
WT_ORIG_BRANCH = _get(data, ["worktree", "original_branch"], "")
|
|
79
|
+
|
|
80
|
+
# ---- Config: line toggles + no_color -------------------------------------
|
|
81
|
+
cfg = {
|
|
82
|
+
"identity": True, "context": True, "cost": True,
|
|
83
|
+
"rate_limits": True, "repo_pr": True, "worktree": True, "no_color": False,
|
|
84
|
+
}
|
|
85
|
+
try:
|
|
86
|
+
with open(os.path.join(root, ".codebyplan", "statusline.json"), "r", encoding="utf-8") as fh:
|
|
87
|
+
parsed = json.load(fh)
|
|
88
|
+
if isinstance(parsed, dict):
|
|
89
|
+
if isinstance(parsed.get("no_color"), bool):
|
|
90
|
+
cfg["no_color"] = parsed["no_color"]
|
|
91
|
+
lines = parsed.get("lines")
|
|
92
|
+
if isinstance(lines, dict):
|
|
93
|
+
for k in ["identity", "context", "cost", "rate_limits", "repo_pr", "worktree"]:
|
|
94
|
+
if isinstance(lines.get(k), bool):
|
|
95
|
+
cfg[k] = lines[k]
|
|
96
|
+
except Exception:
|
|
97
|
+
pass # absent / invalid → keep defaults
|
|
98
|
+
|
|
99
|
+
def should_show(env_suffix, cfg_value):
|
|
100
|
+
# env HIDE=1 > config false > default show
|
|
101
|
+
if os.environ.get("CBP_STATUSLINE_HIDE_" + env_suffix) == "1":
|
|
102
|
+
return False
|
|
103
|
+
if cfg_value is False:
|
|
104
|
+
return False
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
# ---- Colour setup (env > config) -----------------------------------------
|
|
108
|
+
no_color = (
|
|
109
|
+
(os.environ.get("NO_COLOR") not in (None, ""))
|
|
110
|
+
or os.environ.get("CBP_STATUSLINE_NO_COLOR") == "1"
|
|
111
|
+
or cfg["no_color"] is True
|
|
112
|
+
)
|
|
113
|
+
if no_color:
|
|
114
|
+
RST = DIM = BOLD = GREEN = YELLOW = RED = CYAN = MAGENTA = BLUE = ""
|
|
115
|
+
else:
|
|
116
|
+
RST = "\x1b[0m"
|
|
117
|
+
DIM = "\x1b[2m"
|
|
118
|
+
BOLD = "\x1b[1m"
|
|
119
|
+
GREEN = "\x1b[32m"
|
|
120
|
+
YELLOW = "\x1b[33m"
|
|
121
|
+
RED = "\x1b[31m"
|
|
122
|
+
CYAN = "\x1b[36m"
|
|
123
|
+
MAGENTA = "\x1b[35m"
|
|
124
|
+
BLUE = "\x1b[34m"
|
|
125
|
+
|
|
126
|
+
# ---- Numeric helpers (integer round-half-up; cross-runtime identical) -----
|
|
127
|
+
def num_str(n):
|
|
128
|
+
try:
|
|
129
|
+
x = float(n)
|
|
130
|
+
except (TypeError, ValueError):
|
|
131
|
+
return str(n)
|
|
132
|
+
if x.is_integer():
|
|
133
|
+
return str(int(x))
|
|
134
|
+
return str(n)
|
|
135
|
+
|
|
136
|
+
def fmt_k(val):
|
|
137
|
+
v = float(val)
|
|
138
|
+
if v >= 1000000:
|
|
139
|
+
t = math.floor((v + 50000) / 100000)
|
|
140
|
+
return "%d.%dM" % (t // 10, t % 10)
|
|
141
|
+
elif v >= 1000:
|
|
142
|
+
t = math.floor((v + 50) / 100)
|
|
143
|
+
return "%d.%dK" % (t // 10, t % 10)
|
|
144
|
+
return str(int(v))
|
|
145
|
+
|
|
146
|
+
def fmt_cost(c):
|
|
147
|
+
n = math.floor(float(c) * 10000 + 0.5)
|
|
148
|
+
return "$%d.%04d" % (n // 10000, n % 10000)
|
|
149
|
+
|
|
150
|
+
def fmt_dur(ms):
|
|
151
|
+
secs = math.trunc(float(ms) / 1000)
|
|
152
|
+
if secs >= 3600:
|
|
153
|
+
return "%dh%dm" % (secs // 3600, (secs % 3600) // 60)
|
|
154
|
+
if secs >= 60:
|
|
155
|
+
return "%dm%ds" % (secs // 60, secs % 60)
|
|
156
|
+
return "%ds" % secs
|
|
157
|
+
|
|
158
|
+
def cbp_now():
|
|
159
|
+
nv = os.environ.get("CBP_STATUSLINE_NOW")
|
|
160
|
+
if nv not in (None, ""):
|
|
161
|
+
return math.trunc(float(nv))
|
|
162
|
+
return math.floor(time.time())
|
|
163
|
+
|
|
164
|
+
def fmt_rel_time(epoch):
|
|
165
|
+
delta = math.trunc(float(epoch)) - cbp_now()
|
|
166
|
+
if delta <= 0:
|
|
167
|
+
return "now"
|
|
168
|
+
if delta >= 86400:
|
|
169
|
+
return "%dd" % (delta // 86400)
|
|
170
|
+
if delta >= 3600:
|
|
171
|
+
return "%dh" % (delta // 3600)
|
|
172
|
+
return "%dm" % (delta // 60)
|
|
173
|
+
|
|
174
|
+
def gte(v, t):
|
|
175
|
+
try:
|
|
176
|
+
return float(v) >= t
|
|
177
|
+
except (TypeError, ValueError):
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
# ---- Folder + branch -----------------------------------------------------
|
|
181
|
+
folder = ""
|
|
182
|
+
if CWD:
|
|
183
|
+
folder = os.path.basename(CWD)
|
|
184
|
+
elif WS_CURRENT_DIR:
|
|
185
|
+
folder = os.path.basename(WS_CURRENT_DIR)
|
|
186
|
+
branch = WT_BRANCH
|
|
187
|
+
if not branch and CWD:
|
|
188
|
+
try:
|
|
189
|
+
res = subprocess.run(
|
|
190
|
+
["git", "-C", CWD, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
191
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
|
|
192
|
+
)
|
|
193
|
+
branch = res.stdout.rstrip() if res.returncode == 0 else ""
|
|
194
|
+
except Exception:
|
|
195
|
+
branch = ""
|
|
196
|
+
|
|
197
|
+
out = []
|
|
198
|
+
|
|
199
|
+
# ===== LINE 1 — Identity =====
|
|
200
|
+
if should_show("IDENTITY", cfg["identity"]):
|
|
201
|
+
l1 = ""
|
|
202
|
+
if folder:
|
|
203
|
+
l1 = "%s%s%s%s" % (BOLD, BLUE, folder, RST)
|
|
204
|
+
if branch:
|
|
205
|
+
l1 += " %s⎇%s%s%s%s" % (DIM, RST, CYAN, branch, RST)
|
|
206
|
+
l1 += " "
|
|
207
|
+
if WT_NAME:
|
|
208
|
+
l1 += "%swt:%s%s%s%s " % (DIM, RST, MAGENTA, WT_NAME, RST)
|
|
209
|
+
elif SESSION_NAME:
|
|
210
|
+
l1 += "%ssession:%s%s%s%s " % (DIM, RST, MAGENTA, SESSION_NAME, RST)
|
|
211
|
+
elif AGENT_NAME:
|
|
212
|
+
l1 += "%sagent:%s%s%s%s " % (DIM, RST, MAGENTA, AGENT_NAME, RST)
|
|
213
|
+
if MODEL_NAME:
|
|
214
|
+
l1 += "%s%s%s%s" % (BOLD, CYAN, MODEL_NAME, RST)
|
|
215
|
+
elif MODEL_ID:
|
|
216
|
+
l1 += "%s%s%s%s" % (BOLD, CYAN, MODEL_ID, RST)
|
|
217
|
+
if EFFORT:
|
|
218
|
+
l1 += " %seffort:%s%s" % (DIM, RST, EFFORT)
|
|
219
|
+
if THINKING is True:
|
|
220
|
+
l1 += " %sthinking:on%s" % (YELLOW, RST)
|
|
221
|
+
if OUTPUT_STYLE and OUTPUT_STYLE != "default":
|
|
222
|
+
l1 += " %sstyle:%s%s" % (DIM, RST, OUTPUT_STYLE)
|
|
223
|
+
if VIM_MODE:
|
|
224
|
+
l1 += " %s[%s]%s" % (DIM, VIM_MODE, RST)
|
|
225
|
+
if l1:
|
|
226
|
+
out.append(l1)
|
|
227
|
+
|
|
228
|
+
# ===== LINE 2 — Context window =====
|
|
229
|
+
if should_show("CONTEXT", cfg["context"]):
|
|
230
|
+
if gte(CTX_PCT, 75):
|
|
231
|
+
bar_color = RED
|
|
232
|
+
elif gte(CTX_PCT, 50):
|
|
233
|
+
bar_color = YELLOW
|
|
234
|
+
else:
|
|
235
|
+
bar_color = GREEN
|
|
236
|
+
|
|
237
|
+
filled = math.floor((math.trunc(float(CTX_PCT)) + 4) / 5)
|
|
238
|
+
if filled > 20:
|
|
239
|
+
filled = 20
|
|
240
|
+
empty = 20 - filled
|
|
241
|
+
bar = ("▓" * filled) + ("░" * empty)
|
|
242
|
+
|
|
243
|
+
l2 = "%s%s%s %s%s%%%s%s/%s%s" % (
|
|
244
|
+
bar_color, bar, RST, bar_color, num_str(CTX_PCT), RST, DIM, fmt_k(CTX_SIZE), RST,
|
|
245
|
+
)
|
|
246
|
+
l2 += " %sin:%s%s%s%s %sout:%s%s%s%s %scache_cr:%s%s %scache_rd:%s%s" % (
|
|
247
|
+
DIM, RST, BLUE, fmt_k(CUR_IN), RST,
|
|
248
|
+
DIM, RST, MAGENTA, fmt_k(CUR_OUT), RST,
|
|
249
|
+
DIM, RST, fmt_k(CACHE_CREATE),
|
|
250
|
+
DIM, RST, fmt_k(CACHE_READ),
|
|
251
|
+
)
|
|
252
|
+
if EXCEEDS_200K is True:
|
|
253
|
+
l2 += " %s⚠ 200k+%s" % (YELLOW, RST)
|
|
254
|
+
out.append(l2)
|
|
255
|
+
|
|
256
|
+
# ===== LINE 3 — Cost =====
|
|
257
|
+
if should_show("COST", cfg["cost"]):
|
|
258
|
+
l3 = "%s%s%s %sdur:%s%s %sapi:%s%s %s+%s%s %s-%s%s %slines%s" % (
|
|
259
|
+
GREEN, fmt_cost(COST), RST,
|
|
260
|
+
DIM, RST, fmt_dur(DURATION),
|
|
261
|
+
DIM, RST, fmt_dur(API_DURATION),
|
|
262
|
+
GREEN, num_str(LINES_ADD), RST,
|
|
263
|
+
RED, num_str(LINES_DEL), RST,
|
|
264
|
+
DIM, RST,
|
|
265
|
+
)
|
|
266
|
+
out.append(l3)
|
|
267
|
+
|
|
268
|
+
# ===== LINE 4 — Rate limits =====
|
|
269
|
+
if should_show("RATE_LIMITS", cfg["rate_limits"]):
|
|
270
|
+
has_5h = RATE_5H_PCT != "" and str(RATE_5H_RESETS) != "0"
|
|
271
|
+
has_7d = RATE_7D_PCT != "" and str(RATE_7D_RESETS) != "0"
|
|
272
|
+
if has_5h or has_7d:
|
|
273
|
+
l4 = ""
|
|
274
|
+
if has_5h:
|
|
275
|
+
if gte(RATE_5H_PCT, 80):
|
|
276
|
+
c5 = RED
|
|
277
|
+
elif gte(RATE_5H_PCT, 60):
|
|
278
|
+
c5 = YELLOW
|
|
279
|
+
else:
|
|
280
|
+
c5 = GREEN
|
|
281
|
+
l4 = "%s5h:%s%s%s%%%s %s(resets in %s)%s" % (
|
|
282
|
+
DIM, RST, c5, num_str(RATE_5H_PCT), RST, DIM, fmt_rel_time(RATE_5H_RESETS), RST,
|
|
283
|
+
)
|
|
284
|
+
if has_7d:
|
|
285
|
+
if gte(RATE_7D_PCT, 80):
|
|
286
|
+
c7 = RED
|
|
287
|
+
elif gte(RATE_7D_PCT, 60):
|
|
288
|
+
c7 = YELLOW
|
|
289
|
+
else:
|
|
290
|
+
c7 = GREEN
|
|
291
|
+
seg7 = "%s7d:%s%s%s%%%s %s(resets in %s)%s" % (
|
|
292
|
+
DIM, RST, c7, num_str(RATE_7D_PCT), RST, DIM, fmt_rel_time(RATE_7D_RESETS), RST,
|
|
293
|
+
)
|
|
294
|
+
l4 = ("%s %s|%s %s" % (l4, DIM, RST, seg7)) if l4 else seg7
|
|
295
|
+
out.append(l4)
|
|
296
|
+
|
|
297
|
+
# ===== LINE 5 — Repo / PR =====
|
|
298
|
+
if should_show("REPO_PR", cfg["repo_pr"]):
|
|
299
|
+
l5 = ""
|
|
300
|
+
if WS_REPO_HOST:
|
|
301
|
+
l5 = "%s%s/%s/%s%s" % (DIM, WS_REPO_HOST, WS_REPO_OWNER, WS_REPO_NAME, RST)
|
|
302
|
+
if PR_NUMBER != "":
|
|
303
|
+
pr_seg = "%sPR%s %s#%s%s" % (DIM, RST, CYAN, num_str(PR_NUMBER), RST)
|
|
304
|
+
if PR_REVIEW_STATE:
|
|
305
|
+
pr_seg += " %s%s%s" % (DIM, PR_REVIEW_STATE, RST)
|
|
306
|
+
if PR_URL:
|
|
307
|
+
pr_seg += " %s%s%s" % (BLUE, PR_URL, RST)
|
|
308
|
+
l5 = ("%s %s|%s %s" % (l5, DIM, RST, pr_seg)) if l5 else pr_seg
|
|
309
|
+
if l5:
|
|
310
|
+
out.append(l5)
|
|
311
|
+
|
|
312
|
+
# ===== LINE 6 — Worktree =====
|
|
313
|
+
if should_show("WORKTREE", cfg["worktree"]):
|
|
314
|
+
if WT_NAME:
|
|
315
|
+
l6 = "%s%s%s %s@%s %s%s%s" % (MAGENTA, WT_NAME, RST, DIM, RST, CYAN, WT_BRANCH, RST)
|
|
316
|
+
if WT_ORIG_BRANCH and WT_ORIG_BRANCH != WT_BRANCH:
|
|
317
|
+
l6 += " %s(was: %s)%s" % (DIM, WT_ORIG_BRANCH, RST)
|
|
318
|
+
wt_path_disp = WT_PATH
|
|
319
|
+
if len(WT_PATH) > 60:
|
|
320
|
+
wt_path_disp = "..." + WT_PATH[-57:]
|
|
321
|
+
l6 += " %s%s%s" % (DIM, wt_path_disp, RST)
|
|
322
|
+
out.append(l6)
|
|
323
|
+
|
|
324
|
+
sys.stdout.write(("\n".join(out) + "\n") if out else "")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
main()
|
|
329
|
+
except Exception:
|
|
330
|
+
# Statusline must never error to stdout.
|
|
331
|
+
sys.exit(0)
|