claude-finops 0.12.0 → 0.14.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.
@@ -0,0 +1,412 @@
1
+ """Session guard: a PreToolUse hook that keeps a human in the loop on token-heavy sessions.
2
+
3
+ claude-finops --guard invoked by Claude Code before every tool call
4
+ claude-finops --install-guard wire it into ~/.claude/settings.json (see integrate.py)
5
+
6
+ It counts the session's billable tokens (input + output + cache read + cache
7
+ write, the same figure as the dashboard's Tokens column) straight from the
8
+ transcript, reading only what was appended since the last call. Below the
9
+ budget it warns once at each configured percentage; at the budget it asks the
10
+ user to approve the next tool call, then again every step_pct (or never, in
11
+ "once" mode).
12
+
13
+ A hook cannot end a session, and this one never blocks on its own failure:
14
+ anything unexpected means exit 0 with no output, so Claude Code carries on as
15
+ if the guard were not installed.
16
+ """
17
+ import glob
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+ import time
23
+
24
+ from .paths import DATA_DIR, SETTINGS_PATH, LOCAL_SETTINGS_PATH
25
+
26
+ try:
27
+ import fcntl
28
+ except ImportError: # Windows: no advisory locks, concurrent calls are rare there
29
+ fcntl = None
30
+
31
+ STATE_DIR = os.path.join(DATA_DIR, "guard")
32
+ MAX_READ = 64 * 1024 * 1024 # new bytes read per call; the rest waits for the next call
33
+ STALE_S = 30 * 86400
34
+ SWEEP_S = 3600
35
+ SESSION_KEEP_S = 30 * 86400 # a per-session limit is forgotten after this long
36
+ SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
37
+ REJECTED = ("User rejected tool use", "The user doesn't want to proceed with this tool use")
38
+
39
+
40
+ # ---------------------------------------------------------------- settings ----
41
+
42
+ def _num(v):
43
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
44
+
45
+
46
+ def validate_guard(g, default):
47
+ """Clean a guard block. Returns (cleaned, [bad paths]); bad leaves use the default."""
48
+ default = default or {}
49
+ if not isinstance(g, dict):
50
+ return json.loads(json.dumps(default)), (["guard"] if g is not None else [])
51
+ out, bad = dict(default), []
52
+ for k, v in g.items():
53
+ if k.startswith("_"):
54
+ out[k] = v
55
+ elif k == "session_tokens":
56
+ if v is None or (_num(v) and v > 0):
57
+ out[k] = v
58
+ else:
59
+ bad.append(f"guard.{k}")
60
+ elif k == "warn_pct":
61
+ if isinstance(v, list) and all(_num(x) and 0 < x < 100 for x in v):
62
+ out[k] = sorted(set(v))
63
+ else:
64
+ bad.append(f"guard.{k}")
65
+ elif k == "after_approval":
66
+ if v in ("step", "once"):
67
+ out[k] = v
68
+ else:
69
+ bad.append(f"guard.{k}")
70
+ elif k == "step_pct":
71
+ if _num(v) and 1 <= v <= 1000:
72
+ out[k] = v
73
+ else:
74
+ bad.append(f"guard.{k}")
75
+ elif k == "sessions":
76
+ if not isinstance(v, dict):
77
+ bad.append(f"guard.{k}")
78
+ continue
79
+ sessions = {}
80
+ for sid, o in v.items():
81
+ keep = {"set_at": o["set_at"]} if isinstance(o, dict) and isinstance(o.get("set_at"), str) else {}
82
+ if not SAFE_ID.match(str(sid)):
83
+ bad.append(f"guard.sessions.{sid}")
84
+ elif isinstance(o, dict) and o.get("off") is True:
85
+ sessions[sid] = dict(keep, off=True)
86
+ elif isinstance(o, dict) and _num(o.get("session_tokens")) and o["session_tokens"] > 0:
87
+ sessions[sid] = dict(keep, session_tokens=o["session_tokens"])
88
+ else:
89
+ bad.append(f"guard.sessions.{sid}")
90
+ out[k] = sessions
91
+ elif k == "projects":
92
+ if not isinstance(v, dict):
93
+ bad.append(f"guard.{k}")
94
+ continue
95
+ projects = {}
96
+ for path, o in v.items():
97
+ if isinstance(o, dict) and o.get("off") is True:
98
+ projects[path] = {"off": True}
99
+ elif isinstance(o, dict) and _num(o.get("session_tokens")) and o["session_tokens"] > 0:
100
+ projects[path] = {"session_tokens": o["session_tokens"]}
101
+ else:
102
+ bad.append(f"guard.projects.{path}")
103
+ out[k] = projects
104
+ else:
105
+ out[k] = v
106
+ return out, bad
107
+
108
+
109
+ def load_guard_settings():
110
+ """The guard block only: shipped default + settings.local.json, validated.
111
+
112
+ Deliberately not analytics.load_settings(): that also detects the account
113
+ from ~/.claude.json, which the hook has no use for on every tool call.
114
+ """
115
+ with open(SETTINGS_PATH) as fh:
116
+ default = json.load(fh).get("guard") or {}
117
+ local = {}
118
+ try:
119
+ with open(LOCAL_SETTINGS_PATH) as fh:
120
+ local = json.load(fh).get("guard") or {}
121
+ except (OSError, ValueError, AttributeError):
122
+ pass
123
+ merged = dict(default)
124
+ if isinstance(local, dict):
125
+ merged.update(local)
126
+ return validate_guard(merged, default)[0]
127
+
128
+
129
+ def _norm(p):
130
+ return os.path.normcase(os.path.realpath(os.path.expanduser(p)))
131
+
132
+
133
+ def prune_sessions(sessions, now=None):
134
+ """Drop per-session limits set more than 30 days ago (entries without a date stay)."""
135
+ now = now or time.time()
136
+ out = {}
137
+ for sid, o in (sessions or {}).items():
138
+ try:
139
+ age = now - time.mktime(time.strptime(o["set_at"], "%Y-%m-%dT%H:%M:%S"))
140
+ except (KeyError, TypeError, ValueError):
141
+ age = 0
142
+ if age <= SESSION_KEEP_S:
143
+ out[sid] = o
144
+ return out
145
+
146
+
147
+ def has_any_budget(cfg):
148
+ """Is any per-session budget set: global, a project override, or one session's own?"""
149
+ return bool(cfg.get("session_tokens")) or any(
150
+ o.get("session_tokens") for sect in ("projects", "sessions")
151
+ for o in (cfg.get(sect) or {}).values())
152
+
153
+
154
+ def budget_for(cfg, cwd, session_id=None):
155
+ """The budget that applies: this session's own limit, else the most specific
156
+ project override, else the global one."""
157
+ own = (cfg.get("sessions") or {}).get(session_id) if session_id else None
158
+ if own is not None:
159
+ return None if own.get("off") else own.get("session_tokens")
160
+ best, best_len = None, -1
161
+ if cwd:
162
+ here = _norm(cwd)
163
+ for path, o in (cfg.get("projects") or {}).items():
164
+ p = _norm(path)
165
+ if (here == p or here.startswith(p.rstrip(os.sep) + os.sep)) and len(p) > best_len:
166
+ best, best_len = o, len(p)
167
+ if best is not None:
168
+ return None if best.get("off") else best.get("session_tokens")
169
+ return cfg.get("session_tokens")
170
+
171
+
172
+ # ---------------------------------------------------------------- counting ----
173
+
174
+ def billable(u):
175
+ cc = u.get("cache_creation") or {}
176
+ cw = int(u.get("cache_creation_input_tokens") or 0) or (
177
+ int(cc.get("ephemeral_5m_input_tokens") or 0) + int(cc.get("ephemeral_1h_input_tokens") or 0))
178
+ return (int(u.get("input_tokens") or 0) + int(u.get("output_tokens") or 0)
179
+ + int(u.get("cache_read_input_tokens") or 0) + cw)
180
+
181
+
182
+ def session_files(transcript_path, session_id):
183
+ """The transcript plus its subagents' transcripts, which the dashboard counts too."""
184
+ files = [transcript_path]
185
+ sub = os.path.join(os.path.dirname(transcript_path), session_id, "subagents")
186
+ files += sorted(glob.glob(os.path.join(sub, "*.jsonl")))
187
+ return files
188
+
189
+
190
+ def _read_new(path, fs, budget_left, on_row):
191
+ """Feed complete lines appended since fs["offset"] to on_row. Returns bytes read."""
192
+ try:
193
+ size = os.path.getsize(path)
194
+ except OSError:
195
+ return 0
196
+ if size < fs.get("offset", 0): # rewritten: start over
197
+ fs["offset"] = 0
198
+ fs.setdefault("requests", {}).clear()
199
+ if size == fs.get("offset", 0) or budget_left <= 0:
200
+ return 0
201
+ with open(path, "rb") as fh:
202
+ fh.seek(fs.get("offset", 0))
203
+ chunk = fh.read(min(size - fs.get("offset", 0), budget_left))
204
+ end = chunk.rfind(b"\n")
205
+ if end < 0:
206
+ return 0 # a partial line: wait for the rest
207
+ for raw in chunk[:end].split(b"\n"):
208
+ raw = raw.strip()
209
+ if not raw:
210
+ continue
211
+ try:
212
+ row = json.loads(raw)
213
+ except ValueError:
214
+ continue
215
+ if isinstance(row, dict):
216
+ on_row(row)
217
+ fs["offset"] = fs.get("offset", 0) + end + 1
218
+ return end + 1
219
+
220
+
221
+ def count_session(transcript_path, session_id, state, pending_tool=None):
222
+ """Update state["files"] with newly appended lines; return (total billable, tool outcome).
223
+
224
+ Each API request is counted once, from the last line carrying its usage (the
225
+ ETL keys requests the same way: requestId, else message.id, else uuid). The
226
+ outcome is "approved"/"rejected" once the result of pending_tool appears.
227
+ """
228
+ files = state.setdefault("files", {})
229
+ outcome = []
230
+ left = MAX_READ
231
+ for path in session_files(transcript_path, session_id):
232
+ fs = files.setdefault(path, {"offset": 0, "requests": {}})
233
+ reqs = fs.setdefault("requests", {})
234
+
235
+ def on_row(r):
236
+ msg = r.get("message") if isinstance(r.get("message"), dict) else {}
237
+ if r.get("type") == "assistant" and isinstance(msg.get("usage"), dict):
238
+ key = r.get("requestId") or msg.get("id") or r.get("uuid")
239
+ if key:
240
+ reqs[key] = billable(msg["usage"])
241
+ elif pending_tool and r.get("type") == "user":
242
+ for c in msg.get("content") or []:
243
+ if isinstance(c, dict) and c.get("type") == "tool_result" \
244
+ and c.get("tool_use_id") == pending_tool:
245
+ text = json.dumps(c.get("content")) + json.dumps(r.get("toolUseResult"))
246
+ outcome.append("rejected" if any(s in text for s in REJECTED) else "approved")
247
+
248
+ left -= _read_new(path, fs, left, on_row)
249
+ total = sum(sum(f.get("requests", {}).values()) for f in files.values())
250
+ return total, (outcome[-1] if outcome else None)
251
+
252
+
253
+ def session_usage(transcript_path, session_id):
254
+ """The session's billable tokens, counted like the hook does, without saving state."""
255
+ state = {}
256
+ while True:
257
+ before = {p: f.get("offset", 0) for p, f in state.get("files", {}).items()}
258
+ total, _ = count_session(transcript_path, session_id, state)
259
+ after = {p: f.get("offset", 0) for p, f in state.get("files", {}).items()}
260
+ if after == before: # nothing new read: the whole session is counted
261
+ return total
262
+
263
+
264
+ def find_transcript(session_id, roots):
265
+ """<root>/<project>/<session_id>.jsonl for a Claude Code session id, or None."""
266
+ if not SAFE_ID.match(str(session_id or "")):
267
+ return None
268
+ for root in roots:
269
+ hits = glob.glob(os.path.join(root, "*", f"{session_id}.jsonl"))
270
+ if hits:
271
+ return hits[0]
272
+ return None
273
+
274
+
275
+ # ---------------------------------------------------------------- deciding ----
276
+
277
+ def fmt_tokens(n):
278
+ for div, unit in ((1e9, "B"), (1e6, "M"), (1e3, "k")):
279
+ if n >= div:
280
+ s = f"{n / div:.1f}".rstrip("0").rstrip(".")
281
+ return f"{s}{unit}"
282
+ return str(int(n))
283
+
284
+
285
+ def next_ask(cfg, state):
286
+ approved = state.get("approved_pct")
287
+ if approved is None:
288
+ return 100
289
+ if cfg.get("after_approval") == "once":
290
+ return None
291
+ return approved + (cfg.get("step_pct") or 25)
292
+
293
+
294
+ def decide(total, budget, cfg, state, outcome=None, tool_use_id=None):
295
+ """The hook's answer for this tool call (a dict to print), or None to stay silent."""
296
+ if not budget:
297
+ return None
298
+ if state.get("asked_at") is not None and outcome == "approved":
299
+ state["approved_pct"], state["asked_at"], state["asked_tool"] = state["asked_at"], None, None
300
+ elif outcome == "rejected":
301
+ state["asked_at"], state["asked_tool"] = None, None # declined: ask again next time
302
+ pct = 100.0 * total / budget
303
+ level = next_ask(cfg, state)
304
+ if level is not None and pct >= level:
305
+ state["asked_at"], state["asked_tool"] = level, tool_use_id
306
+ # the ask supersedes any warning it jumped past; don't replay them after approval
307
+ state["warned"] = sorted(set(state.get("warned") or [])
308
+ | {w for w in (cfg.get("warn_pct") or []) if pct >= w})
309
+ after = next_ask(cfg, dict(state, approved_pct=level))
310
+ hint = (f"Approving continues until {after:g}%." if after is not None
311
+ else "Approving turns the guard off for the rest of this session.")
312
+ return {"hookSpecificOutput": {
313
+ "hookEventName": "PreToolUse",
314
+ "permissionDecision": "ask",
315
+ "permissionDecisionReason":
316
+ f"Session guard: this session has used {fmt_tokens(total)} tokens, "
317
+ f"{pct:.0f}% of its {fmt_tokens(budget)} budget. Allow this tool call? {hint}"}}
318
+ warned = set(state.get("warned") or [])
319
+ crossed = [w for w in (cfg.get("warn_pct") or []) if pct >= w and w not in warned]
320
+ if crossed:
321
+ state["warned"] = sorted(warned | set(crossed))
322
+ return {"systemMessage": f"Session guard: {fmt_tokens(total)} tokens, {pct:.0f}% of "
323
+ f"this session's {fmt_tokens(budget)} budget.",
324
+ "hookSpecificOutput": {
325
+ "hookEventName": "PreToolUse",
326
+ "additionalContext":
327
+ f"This session has used {pct:.0f}% of its token budget. At a natural "
328
+ "break, suggest the user run /compact or start a fresh session."}}
329
+ return None
330
+
331
+
332
+ # ------------------------------------------------------------------- state ----
333
+
334
+ def _state_path(session_id):
335
+ safe = "".join(ch for ch in session_id if ch.isalnum() or ch in "-_")
336
+ return os.path.join(STATE_DIR, f"{safe or 'unknown'}.json")
337
+
338
+
339
+ def _load_state(path):
340
+ try:
341
+ with open(path) as fh:
342
+ s = json.load(fh)
343
+ return s if isinstance(s, dict) else {}
344
+ except (OSError, ValueError):
345
+ return {}
346
+
347
+
348
+ def _save_state(path, state):
349
+ state["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S")
350
+ tmp = f"{path}.{os.getpid()}.tmp"
351
+ with open(tmp, "w") as fh:
352
+ json.dump(state, fh)
353
+ os.replace(tmp, path)
354
+
355
+
356
+ def _sweep():
357
+ """Drop state for sessions untouched for 30 days; at most once an hour."""
358
+ marker = os.path.join(STATE_DIR, ".swept")
359
+ now = time.time()
360
+ try:
361
+ if now - os.path.getmtime(marker) < SWEEP_S:
362
+ return
363
+ except OSError:
364
+ pass
365
+ for p in glob.glob(os.path.join(STATE_DIR, "*.json")) + glob.glob(os.path.join(STATE_DIR, "*.lock")):
366
+ try:
367
+ if now - os.path.getmtime(p) > STALE_S:
368
+ os.remove(p)
369
+ except OSError:
370
+ pass
371
+ with open(marker, "w"):
372
+ pass
373
+
374
+
375
+ # -------------------------------------------------------------------- hook ----
376
+
377
+ def run(payload, cfg=None):
378
+ """Decide for one PreToolUse payload. Returns the dict to print, or None."""
379
+ session_id = payload.get("session_id")
380
+ transcript = payload.get("transcript_path")
381
+ if not session_id or not transcript:
382
+ return None
383
+ cfg = cfg if cfg is not None else load_guard_settings()
384
+ budget = budget_for(cfg, payload.get("cwd"), session_id)
385
+ if not budget:
386
+ return None
387
+ os.makedirs(STATE_DIR, exist_ok=True)
388
+ path = _state_path(session_id)
389
+ lock = open(path + ".lock", "w")
390
+ try:
391
+ if fcntl:
392
+ fcntl.flock(lock, fcntl.LOCK_EX) # parallel tool calls share one state file
393
+ state = _load_state(path)
394
+ total, outcome = count_session(transcript, session_id, state, state.get("asked_tool"))
395
+ out = decide(total, budget, cfg, state, outcome, payload.get("tool_use_id"))
396
+ _save_state(path, state)
397
+ finally:
398
+ lock.close()
399
+ _sweep()
400
+ return out
401
+
402
+
403
+ def main():
404
+ try:
405
+ raw = sys.stdin.read()
406
+ payload = json.loads(raw) if raw.strip() else {}
407
+ out = run(payload) if isinstance(payload, dict) else None
408
+ if out:
409
+ print(json.dumps(out))
410
+ except Exception:
411
+ pass # fail open: never stand between the user and their session
412
+ return 0
@@ -4,6 +4,7 @@
4
4
  claude-finops --install-statusline wire it into settings
5
5
  claude-finops --hook retired no-op, kept so existing installs do not error
6
6
  claude-finops --install-hook prints that the hook is retired and does nothing else
7
+ claude-finops --install-guard wire the session guard (finops/guard.py) into settings
7
8
 
8
9
  The prompt hook that used to "suggest a cheaper model" is retired: that
9
10
  suggestion had no basis — it would have meant repricing work that never ran.
@@ -89,6 +90,19 @@ def _command():
89
90
  return f"{shlex.quote(sys.executable)} {shlex.quote(os.path.join(root, 'run.py'))}"
90
91
 
91
92
 
93
+ def _guard_command():
94
+ """The hook command: this installation's own code, and never a blocking exit.
95
+
96
+ Not the claude-finops on PATH: that can be an older release without --guard,
97
+ and its "unknown option" exit code 2 is what Claude Code reads as "block this
98
+ tool call". `|| true` keeps any failure (missing Python, a moved install) from
99
+ ever blocking; a successful run's JSON on stdout passes through unchanged.
100
+ """
101
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
102
+ run = f"{shlex.quote(sys.executable)} {shlex.quote(os.path.join(root, 'run.py'))}"
103
+ return f"{run} --guard || true"
104
+
105
+
92
106
  def _load_settings():
93
107
  try:
94
108
  with open(SETTINGS) as fh:
@@ -146,3 +160,67 @@ def install_statusline(remove=False):
146
160
  return {"ok": True, "message": f"Installed the statusline in {SETTINGS}. It shows the model, "
147
161
  "context % and 5-hour limit % under every prompt. "
148
162
  "Undo: claude-finops --uninstall-statusline"}
163
+
164
+
165
+ # ------------------------------------------------------------------- guard ----
166
+
167
+ def _is_guard(h):
168
+ cmd = h.get("command") if isinstance(h, dict) else None
169
+ return isinstance(cmd, str) and "--guard" in cmd.split() and "finops" in cmd
170
+
171
+
172
+ def guard_state():
173
+ """"installed" when our PreToolUse guard hook is in settings, else None."""
174
+ groups = ((_load_settings().get("hooks") or {}).get("PreToolUse")) or []
175
+ for g in groups if isinstance(groups, list) else []:
176
+ if isinstance(g, dict) and any(_is_guard(h) for h in g.get("hooks") or []):
177
+ return "installed"
178
+ return None
179
+
180
+
181
+ def install_guard(remove=False):
182
+ """Add (or remove) our PreToolUse hook. Returns {"ok", "message"}.
183
+
184
+ Only our own entry is ever touched: other PreToolUse hooks, and other hook
185
+ events, are left exactly as they were.
186
+ """
187
+ s = _load_settings()
188
+ hooks = s.get("hooks") if isinstance(s.get("hooks"), dict) else {}
189
+ groups = hooks.get("PreToolUse") if isinstance(hooks.get("PreToolUse"), list) else []
190
+ if remove:
191
+ if guard_state() is None:
192
+ return {"ok": True, "message": "The session guard is not installed; nothing changed."}
193
+ kept = []
194
+ for g in groups:
195
+ if isinstance(g, dict) and isinstance(g.get("hooks"), list):
196
+ g = dict(g, hooks=[h for h in g["hooks"] if not _is_guard(h)])
197
+ if not g["hooks"]:
198
+ continue
199
+ kept.append(g)
200
+ if kept:
201
+ hooks["PreToolUse"] = kept
202
+ else:
203
+ hooks.pop("PreToolUse", None)
204
+ if hooks:
205
+ s["hooks"] = hooks
206
+ else:
207
+ s.pop("hooks", None)
208
+ _save_settings(s)
209
+ return {"ok": True, "message": f"Removed the session guard from {SETTINGS}"}
210
+ if guard_state() == "installed":
211
+ return {"ok": True, "message": "The session guard is already installed."}
212
+ groups.append({"matcher": "", "hooks": [
213
+ {"type": "command", "command": _guard_command(), "timeout": 10}]})
214
+ hooks["PreToolUse"] = groups
215
+ s["hooks"] = hooks
216
+ _save_settings(s)
217
+ msg = (f"Installed the session guard in {SETTINGS}. It applies to new Claude Code sessions. "
218
+ "Undo: claude-finops --uninstall-guard")
219
+ try:
220
+ from .guard import load_guard_settings, has_any_budget
221
+ if not has_any_budget(load_guard_settings()):
222
+ msg += (" It stays inactive until you set a per-session token budget "
223
+ "(Budgets page, or guard.session_tokens in settings.local.json).")
224
+ except Exception:
225
+ pass
226
+ return {"ok": True, "message": msg}
package/finops/jev.py ADDED
@@ -0,0 +1,175 @@
1
+ """Jev (TypeSafe's "System One" model): install its Claude Code plugin, keep its API key.
2
+
3
+ Jev makes fast, typed decisions (pick one, yes or no, a score, a value). It does not write
4
+ text or code, so it cannot stand in for the model behind Claude Code; the plugin teaches
5
+ Claude how to use Jev in the software you build. https://docs.typesafe.ai
6
+
7
+ install claude plugin marketplace add typesafe-ai/skills
8
+ claude plugin install typesafe@typesafe-ai
9
+ uninstall claude plugin uninstall typesafe@typesafe-ai
10
+ API key env.TYPESAFE_API_KEY in ~/.claude/settings.json, which Claude Code hands to
11
+ every new session (the page never gets the key back, only its last four)
12
+
13
+ Every command is a fixed argument list, run only when the user clicks.
14
+ """
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+
21
+ from . import integrate
22
+
23
+ PLUGIN = "typesafe@typesafe-ai"
24
+ MARKETPLACE = "typesafe-ai/skills"
25
+ ENV_KEY = "TYPESAFE_API_KEY"
26
+ JEV_USD_PER_INPUT_TOKEN = 0.042 / 1e6 # TypeSafe's published price; output is free
27
+
28
+
29
+ # ------------------------------------------------------------------ plugin ----
30
+
31
+ def _claude():
32
+ return shutil.which("claude")
33
+
34
+
35
+ def _run(cmd, log):
36
+ """Run one fixed command, log every output line, and put its last lines in any error
37
+ (so "already added" can be recognised). No shell."""
38
+ try:
39
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
40
+ except (OSError, subprocess.SubprocessError) as e:
41
+ raise RuntimeError(f"{' '.join(cmd[1:4])} could not run: {e}") from None
42
+ lines = [ln.strip() for ln in (r.stdout + "\n" + r.stderr).splitlines() if ln.strip()]
43
+ for ln in lines:
44
+ log(ln[-200:])
45
+ if r.returncode:
46
+ raise RuntimeError(f"{' '.join(cmd[1:4])} failed: " + (" ".join(lines[-3:]) or f"exit {r.returncode}"))
47
+
48
+
49
+ def status():
50
+ """Is the plugin installed? Never raises: a broken `claude` just reads as not installed."""
51
+ exe = _claude()
52
+ out = {"claude": bool(exe), "installed": False, "version": None, "enabled": None}
53
+ if not exe:
54
+ return out
55
+ try:
56
+ r = subprocess.run([exe, "plugin", "list", "--json"], capture_output=True, text=True,
57
+ timeout=20)
58
+ rows = json.loads(r.stdout or "[]")
59
+ except (OSError, ValueError, subprocess.SubprocessError):
60
+ return out
61
+ for p in rows if isinstance(rows, list) else []:
62
+ if isinstance(p, dict) and p.get("id") == PLUGIN:
63
+ out.update(installed=True, version=p.get("version"), enabled=p.get("enabled"))
64
+ return out
65
+
66
+
67
+ def _need_claude():
68
+ exe = _claude()
69
+ if not exe:
70
+ raise RuntimeError("Claude Code's `claude` command was not found. Install Claude Code, "
71
+ "or start the dashboard from a terminal where `claude` works.")
72
+ return exe
73
+
74
+
75
+ def install(log):
76
+ exe = _need_claude()
77
+ log(f"Adding TypeSafe's plugin list: claude plugin marketplace add {MARKETPLACE}")
78
+ try:
79
+ _run([exe, "plugin", "marketplace", "add", MARKETPLACE], log)
80
+ except RuntimeError as e:
81
+ if "already" not in str(e).lower():
82
+ raise
83
+ log("Already added. Carrying on.")
84
+ log(f"Installing the plugin: claude plugin install {PLUGIN}")
85
+ _run([exe, "plugin", "install", PLUGIN], log)
86
+ log("Installed. New Claude Code sessions can use it; restart any open ones.")
87
+ return status()
88
+
89
+
90
+ def uninstall(log):
91
+ exe = _need_claude()
92
+ log(f"Removing the plugin: claude plugin uninstall {PLUGIN}")
93
+ _run([exe, "plugin", "uninstall", PLUGIN], log)
94
+ log("Removed.")
95
+ return status()
96
+
97
+
98
+ # --------------------------------------------------------------------- key ----
99
+
100
+ def key_status():
101
+ """Where the key comes from and its last four characters. Never the key."""
102
+ shell = os.environ.get(ENV_KEY)
103
+ stored = ((integrate._load_settings().get("env") or {}).get(ENV_KEY)) or ""
104
+ key = shell or stored
105
+ return {"source": "shell" if shell else "claude_settings" if stored else None,
106
+ "last4": key[-4:] if len(key) >= 12 else None}
107
+
108
+
109
+ def save_key(value):
110
+ value = (value or "").strip()
111
+ if not value:
112
+ raise ValueError("Paste your Jev API key first.")
113
+ if any(ch.isspace() for ch in value):
114
+ raise ValueError("A key has no spaces or line breaks in it.")
115
+ if len(value) < 16:
116
+ raise ValueError("That looks too short for a TypeSafe API key.")
117
+ s = integrate._load_settings()
118
+ env = s.get("env") if isinstance(s.get("env"), dict) else {}
119
+ env[ENV_KEY] = value
120
+ s["env"] = env
121
+ integrate._save_settings(s)
122
+
123
+
124
+ def remove_key():
125
+ s = integrate._load_settings()
126
+ env = s.get("env") if isinstance(s.get("env"), dict) else {}
127
+ if ENV_KEY not in env:
128
+ return
129
+ env.pop(ENV_KEY)
130
+ if env:
131
+ s["env"] = env
132
+ else:
133
+ s.pop("env", None)
134
+ integrate._save_settings(s)
135
+
136
+
137
+ # --------------------------------------------------------------- where it fits ----
138
+ # A prompt Jev could have answered: short (at most two model calls), no tools, a short
139
+ # reply, and worded as a decision rather than a request to build something.
140
+ DECIDE = re.compile(r"\b(classify|categori[sz]e|category|label|which (one|of)|yes or no|true or false|"
141
+ r"is (this|it) (a|an)\b|extract|score|rate this|triage|route|detect|decide|pick one|"
142
+ r"choose|prioriti[sz]e|sentiment|is it valid|should (i|we)\b|does (this|it)\b)", re.I)
143
+ BUILD = re.compile(r"\b(write|implement|build|create|fix|refactor|add|update|change|make|generate|"
144
+ r"design|explain|debug)\b", re.I)
145
+ # Claude Code's own summary after a context compaction is stored like a prompt; it is not yours.
146
+ NOT_YOURS = ("This session is being continued from a previous conversation",)
147
+ _MASK = re.compile(r"\S+@\S+|(?:~|\.{0,2})?/[\w.\-/]+|\d{3,}")
148
+
149
+
150
+ def _mask(text):
151
+ return _MASK.sub("…", re.sub(r"\s+", " ", text or "")).strip()[:140]
152
+
153
+
154
+ def fit(a, f=None):
155
+ """How much of the filtered Claude Code work was decision-shaped, and what it would cost on Jev."""
156
+ w, p = a.where(f)
157
+ total = a.one(f"SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r "
158
+ f"WHERE {w} AND r.agent='claude'", p)["c"]
159
+ rows = a.q(f"""SELECT pr.text, pr.request_count, pr.output_tokens, pr.est_cost_usd cost,
160
+ COALESCE(pr.input_tokens,0) + COALESCE(pr.cache_read_tokens,0)
161
+ + COALESCE(pr.cache_write_tokens,0) ctx
162
+ FROM prompts pr WHERE pr.agent='claude' AND pr.id IN (
163
+ SELECT r.prompt_id FROM requests r WHERE {w} AND r.agent='claude')
164
+ AND COALESCE(pr.request_count,0) <= 2 AND COALESCE(pr.tool_calls,0) = 0
165
+ AND COALESCE(pr.output_tokens,0) <= 300""", p)
166
+ hits = [r for r in rows if DECIDE.search(r["text"] or "") and not BUILD.search((r["text"] or "")[:200])
167
+ and not (r["text"] or "").lstrip().startswith(NOT_YOURS)]
168
+ n_all = a.one(f"SELECT COUNT(DISTINCT r.prompt_id) n FROM requests r "
169
+ f"WHERE {w} AND r.agent='claude' AND r.prompt_id IS NOT NULL", p)["n"]
170
+ cost = sum(r["cost"] or 0 for r in hits)
171
+ return {"prompts": len(hits), "total_prompts": n_all, "claude_cost": cost,
172
+ "jev_cost": sum(r["ctx"] or 0 for r in hits) * JEV_USD_PER_INPUT_TOKEN,
173
+ "share_pct": 100.0 * cost / total if total else 0.0, "total_cost": total,
174
+ "examples": [{"text": _mask(r["text"]), "cost": r["cost"] or 0}
175
+ for r in sorted(hits, key=lambda r: -(r["cost"] or 0))[:5]]}