@whenlabs/when 0.9.2 → 0.10.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/README.md +26 -3
- package/dist/chunk-JOMP6AU5.js +40 -0
- package/dist/index.js +392 -50
- package/dist/install-33GE3HKA.js +190 -0
- package/dist/mcp.js +757 -597
- package/package.json +3 -1
- package/templates/statusline.py +311 -0
- package/dist/install-F46OPKIA.js +0 -484
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whenlabs/when",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The WhenLabs developer toolkit — 6 tools, one install",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"@whenlabs/velocity-mcp": "^0.1.3",
|
|
37
37
|
"@whenlabs/vow": "^0.1.4",
|
|
38
38
|
"commander": "^12.0.0",
|
|
39
|
+
"yaml": "^2.8.3",
|
|
39
40
|
"zod": "^4.3.6"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
},
|
|
47
48
|
"files": [
|
|
48
49
|
"dist",
|
|
50
|
+
"templates",
|
|
49
51
|
"action.yml"
|
|
50
52
|
]
|
|
51
53
|
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""WhenLabs status line for Claude Code — with proactive background tool scans."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
CACHE_DIR = Path.home() / ".whenlabs" / "cache"
|
|
12
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
# Scan intervals in seconds
|
|
15
|
+
INTERVALS = {
|
|
16
|
+
"berth": 900, # 15 min
|
|
17
|
+
"stale": 1800, # 30 min
|
|
18
|
+
"envalid": 1800, # 30 min
|
|
19
|
+
"vow": 3600, # 60 min
|
|
20
|
+
"aware": 3600, # 60 min
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class C:
|
|
25
|
+
AMBER = "\033[38;2;196;106;26m"
|
|
26
|
+
BLUE = "\033[38;2;59;130;246m"
|
|
27
|
+
CYAN = "\033[38;2;34;211;238m"
|
|
28
|
+
GREEN = "\033[38;2;34;197;94m"
|
|
29
|
+
RED = "\033[38;2;239;68;68m"
|
|
30
|
+
YELLOW = "\033[38;2;234;179;8m"
|
|
31
|
+
GRAY = "\033[38;2;156;163;175m"
|
|
32
|
+
DIM = "\033[2m"
|
|
33
|
+
RESET = "\033[0m"
|
|
34
|
+
SEP = f"\033[38;2;107;114;128m \u30fb \033[0m"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def git_info(cwd):
|
|
38
|
+
try:
|
|
39
|
+
subprocess.run(["git", "rev-parse", "--git-dir"], cwd=cwd, capture_output=True, check=True, timeout=1)
|
|
40
|
+
branch = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, capture_output=True, text=True, timeout=1).stdout.strip()
|
|
41
|
+
status = subprocess.run(["git", "status", "--porcelain"], cwd=cwd, capture_output=True, text=True, timeout=1).stdout
|
|
42
|
+
clean = len([l for l in status.strip().split("\n") if l]) == 0
|
|
43
|
+
color = C.GREEN if clean else C.YELLOW
|
|
44
|
+
icon = "\u2713" if clean else "\u00b1"
|
|
45
|
+
return f"{color}{branch} {icon}{C.RESET}"
|
|
46
|
+
except Exception:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def mcp_servers(data):
|
|
51
|
+
servers = []
|
|
52
|
+
try:
|
|
53
|
+
cfg = Path.home() / ".claude.json"
|
|
54
|
+
if cfg.exists():
|
|
55
|
+
with open(cfg) as f:
|
|
56
|
+
servers.extend(json.load(f).get("mcpServers", {}).keys())
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
cwd = data.get("workspace", {}).get("current_dir", "")
|
|
60
|
+
if cwd:
|
|
61
|
+
for p in [Path(cwd) / ".mcp.json", Path(cwd) / ".claude" / ".mcp.json"]:
|
|
62
|
+
try:
|
|
63
|
+
if p.exists():
|
|
64
|
+
with open(p) as f:
|
|
65
|
+
servers.extend(json.load(f).get("mcpServers", {}).keys())
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
seen = set()
|
|
69
|
+
return [s for s in servers if not (s in seen or seen.add(s))]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def context_pct(data):
|
|
73
|
+
try:
|
|
74
|
+
cw = data["context_window"]
|
|
75
|
+
size = cw["context_window_size"]
|
|
76
|
+
usage = cw.get("current_usage", {})
|
|
77
|
+
tokens = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0)
|
|
78
|
+
pct = (tokens * 100) // size
|
|
79
|
+
color = C.GREEN if pct < 40 else C.YELLOW if pct < 70 else C.RED
|
|
80
|
+
return f"{C.DIM}{color}{pct}%{C.RESET}"
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cost_info(data):
|
|
86
|
+
try:
|
|
87
|
+
cfg = Path.home() / ".claude.json"
|
|
88
|
+
if cfg.exists():
|
|
89
|
+
with open(cfg) as f:
|
|
90
|
+
if json.load(f).get("oauthAccount", {}).get("accountUuid"):
|
|
91
|
+
return None
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
try:
|
|
95
|
+
cost = data.get("cost", {}).get("total_cost_usd")
|
|
96
|
+
if cost:
|
|
97
|
+
color = C.GREEN if cost < 1 else C.YELLOW if cost < 5 else C.RED
|
|
98
|
+
return f"{color}${cost:.2f}{C.RESET}"
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- Background tool scanning ---
|
|
105
|
+
|
|
106
|
+
def cache_path(tool, cwd):
|
|
107
|
+
project = Path(cwd).name if cwd else "global"
|
|
108
|
+
return CACHE_DIR / f"{tool}_{project}.json"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def should_run(tool, cwd):
|
|
112
|
+
cp = cache_path(tool, cwd)
|
|
113
|
+
if not cp.exists():
|
|
114
|
+
return True
|
|
115
|
+
try:
|
|
116
|
+
cached = json.loads(cp.read_text())
|
|
117
|
+
return (time.time() - cached.get("timestamp", 0)) > INTERVALS[tool]
|
|
118
|
+
except Exception:
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def run_bg(tool, args, cwd):
|
|
123
|
+
cp = cache_path(tool, cwd)
|
|
124
|
+
snippet = (
|
|
125
|
+
"import subprocess, json, time, os; "
|
|
126
|
+
f"args = {args!r}; "
|
|
127
|
+
f"cwd = {cwd!r}; "
|
|
128
|
+
f"out_path = {str(cp)!r}; "
|
|
129
|
+
"env = {**os.environ, 'FORCE_COLOR': '0', 'NO_COLOR': '1'}; "
|
|
130
|
+
"r = subprocess.run(args, cwd=cwd, capture_output=True, text=True, env=env, timeout=60); "
|
|
131
|
+
"cache = {'timestamp': time.time(), 'output': r.stdout + r.stderr, 'code': r.returncode}; "
|
|
132
|
+
"open(out_path, 'w').write(json.dumps(cache))"
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
subprocess.Popen(
|
|
136
|
+
[sys.executable, "-c", snippet],
|
|
137
|
+
stdout=subprocess.DEVNULL,
|
|
138
|
+
stderr=subprocess.DEVNULL,
|
|
139
|
+
start_new_session=True,
|
|
140
|
+
)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_cache(tool, cwd):
|
|
146
|
+
cp = cache_path(tool, cwd)
|
|
147
|
+
if not cp.exists():
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
return json.loads(cp.read_text())
|
|
151
|
+
except Exception:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_stale(cached):
|
|
156
|
+
if not cached or cached["code"] != 0:
|
|
157
|
+
return None
|
|
158
|
+
out = cached["output"]
|
|
159
|
+
drifted = out.count("\u2717")
|
|
160
|
+
if drifted > 0:
|
|
161
|
+
return f"{C.RED}stale:{drifted}{C.RESET}"
|
|
162
|
+
if "\u2713" in out or "No drift" in out.lower() or "clean" in out.lower():
|
|
163
|
+
return f"{C.GREEN}stale:ok{C.RESET}"
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_envalid(cached):
|
|
168
|
+
if not cached:
|
|
169
|
+
return None
|
|
170
|
+
out = cached["output"]
|
|
171
|
+
if cached["code"] != 0:
|
|
172
|
+
errors = sum(1 for line in out.split("\n") if "\u2717" in line or "error" in line.lower() or "missing" in line.lower())
|
|
173
|
+
if errors > 0:
|
|
174
|
+
return f"{C.RED}env:{errors}{C.RESET}"
|
|
175
|
+
return f"{C.YELLOW}env:?{C.RESET}"
|
|
176
|
+
return f"{C.GREEN}env:ok{C.RESET}"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_berth(cached):
|
|
180
|
+
if not cached:
|
|
181
|
+
return None
|
|
182
|
+
out = cached["output"]
|
|
183
|
+
conflicts = out.lower().count("conflict")
|
|
184
|
+
if conflicts > 0:
|
|
185
|
+
return f"{C.RED}ports:{conflicts}{C.RESET}"
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_vow(cached):
|
|
190
|
+
if not cached:
|
|
191
|
+
return None
|
|
192
|
+
out = cached["output"]
|
|
193
|
+
unknown = 0
|
|
194
|
+
for line in out.split("\n"):
|
|
195
|
+
low = line.lower()
|
|
196
|
+
if "unknown" in low or "unlicensed" in low:
|
|
197
|
+
for word in line.split():
|
|
198
|
+
if word.isdigit():
|
|
199
|
+
unknown += int(word)
|
|
200
|
+
break
|
|
201
|
+
else:
|
|
202
|
+
unknown += 1
|
|
203
|
+
if unknown > 0:
|
|
204
|
+
return f"{C.YELLOW}lic:{unknown}?{C.RESET}"
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def parse_aware(cached):
|
|
209
|
+
if not cached:
|
|
210
|
+
return None
|
|
211
|
+
out = cached["output"]
|
|
212
|
+
if cached["code"] != 0 or "stale" in out.lower() or "outdated" in out.lower() or "drift" in out.lower():
|
|
213
|
+
return f"{C.YELLOW}aware:stale{C.RESET}"
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def run_scans(cwd):
|
|
218
|
+
if not cwd:
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
scans = {
|
|
222
|
+
"stale": (["npx", "--yes", "@whenlabs/stale", "scan"], parse_stale),
|
|
223
|
+
"envalid": (["npx", "--yes", "@whenlabs/envalid", "validate"], parse_envalid),
|
|
224
|
+
"berth": (["npx", "--yes", "@whenlabs/berth", "check", "."], parse_berth),
|
|
225
|
+
"vow": (["npx", "--yes", "@whenlabs/vow", "scan"], parse_vow),
|
|
226
|
+
"aware": (["npx", "--yes", "@whenlabs/aware", "doctor"], parse_aware),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for tool, (args, _) in scans.items():
|
|
230
|
+
if should_run(tool, cwd):
|
|
231
|
+
run_bg(tool, args, cwd)
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
# Auto-sync aware when staleness detected
|
|
235
|
+
aware_cached = read_cache("aware", cwd)
|
|
236
|
+
if aware_cached:
|
|
237
|
+
out = aware_cached.get("output", "")
|
|
238
|
+
code = aware_cached.get("code", 0)
|
|
239
|
+
if code != 0 or "stale" in out.lower() or "outdated" in out.lower() or "drift" in out.lower() or "never synced" in out.lower():
|
|
240
|
+
sync_cache = cache_path("aware_sync", cwd)
|
|
241
|
+
sync_age = 0
|
|
242
|
+
if sync_cache.exists():
|
|
243
|
+
try:
|
|
244
|
+
sync_age = time.time() - json.loads(sync_cache.read_text()).get("timestamp", 0)
|
|
245
|
+
except Exception:
|
|
246
|
+
sync_age = 99999
|
|
247
|
+
else:
|
|
248
|
+
sync_age = 99999
|
|
249
|
+
if sync_age > 3600: # Only auto-sync once per hour
|
|
250
|
+
run_bg("aware_sync", ["npx", "--yes", "@whenlabs/aware", "sync"], cwd)
|
|
251
|
+
|
|
252
|
+
results = []
|
|
253
|
+
for tool, (_, parser) in scans.items():
|
|
254
|
+
cached = read_cache(tool, cwd)
|
|
255
|
+
if cached:
|
|
256
|
+
parsed = parser(cached)
|
|
257
|
+
if parsed:
|
|
258
|
+
results.append(parsed)
|
|
259
|
+
|
|
260
|
+
return results
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main():
|
|
264
|
+
try:
|
|
265
|
+
data = json.loads(sys.stdin.read())
|
|
266
|
+
except Exception:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
parts = []
|
|
270
|
+
|
|
271
|
+
cwd = data.get("workspace", {}).get("current_dir", "")
|
|
272
|
+
if cwd:
|
|
273
|
+
parts.append(f"{C.BLUE}{Path(cwd).name}{C.RESET}")
|
|
274
|
+
|
|
275
|
+
if cwd:
|
|
276
|
+
g = git_info(cwd)
|
|
277
|
+
if g:
|
|
278
|
+
parts.append(g)
|
|
279
|
+
|
|
280
|
+
servers = mcp_servers(data)
|
|
281
|
+
if servers:
|
|
282
|
+
names = " ".join(servers)
|
|
283
|
+
parts.append(f"{C.AMBER}{C.DIM}{names}{C.RESET}")
|
|
284
|
+
|
|
285
|
+
c = cost_info(data)
|
|
286
|
+
if c:
|
|
287
|
+
parts.append(c)
|
|
288
|
+
|
|
289
|
+
model = data.get("model", {}).get("display_name", "")
|
|
290
|
+
if model:
|
|
291
|
+
short = "".join(ch for ch in model if ch.isalpha()).lower()
|
|
292
|
+
parts.append(f"{C.GRAY}{short}{C.RESET}")
|
|
293
|
+
|
|
294
|
+
ctx = context_pct(data)
|
|
295
|
+
if ctx:
|
|
296
|
+
parts.append(ctx)
|
|
297
|
+
|
|
298
|
+
ver = data.get("version")
|
|
299
|
+
if ver:
|
|
300
|
+
parts.append(f"{C.DIM}{C.GRAY}v{ver}{C.RESET}")
|
|
301
|
+
|
|
302
|
+
scan_results = run_scans(cwd)
|
|
303
|
+
|
|
304
|
+
print(C.SEP.join(parts))
|
|
305
|
+
|
|
306
|
+
if scan_results:
|
|
307
|
+
print(f"{C.DIM}{C.GRAY} tools:{C.RESET} {f' {C.DIM}|{C.RESET} '.join(scan_results)}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
main()
|