@whenlabs/when 0.9.2 → 0.9.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whenlabs/when",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "The WhenLabs developer toolkit — 6 tools, one install",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "files": [
48
48
  "dist",
49
+ "templates",
49
50
  "action.yml"
50
51
  ]
51
52
  }
@@ -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()