claude-finops 0.1.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/LICENSE +21 -0
- package/README.md +361 -0
- package/bin/claude-finops.js +74 -0
- package/config/free_models.json +49 -0
- package/config/model_compare.json +13 -0
- package/config/pricing.json +232 -0
- package/config/settings.json +57 -0
- package/finops/__init__.py +0 -0
- package/finops/actions.py +671 -0
- package/finops/agents.py +296 -0
- package/finops/analytics.py +1579 -0
- package/finops/api.py +426 -0
- package/finops/classify.py +48 -0
- package/finops/cloud.py +336 -0
- package/finops/diagnose.py +1002 -0
- package/finops/etl.py +533 -0
- package/finops/paths.py +111 -0
- package/finops/playbook.py +229 -0
- package/finops/pricing.py +74 -0
- package/finops/procs.py +499 -0
- package/finops/report.py +169 -0
- package/package.json +52 -0
- package/run.cmd +4 -0
- package/run.py +210 -0
- package/run.sh +3 -0
- package/web/app.js +2923 -0
- package/web/charts.js +340 -0
- package/web/index.html +14 -0
- package/web/styles.css +387 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""Actions that change this machine: sync, free-model launchers, skills and MCP servers.
|
|
2
|
+
|
|
3
|
+
Every action is two steps: plan() says what will happen and what it needs from the user
|
|
4
|
+
(consent, an API key); run() only proceeds with that consent. Long steps run as jobs whose
|
|
5
|
+
log the UI polls.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import sqlite3
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
import uuid
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
|
|
21
|
+
from .analytics import DB_PATH, ROOT
|
|
22
|
+
from .etl import DEFAULT_SOURCE, Loader
|
|
23
|
+
|
|
24
|
+
HOME = os.path.expanduser("~")
|
|
25
|
+
BIN_DIR = os.path.join(HOME, ".local", "bin")
|
|
26
|
+
KEY_DIR = os.path.join(HOME, ".config", "claude-free-models")
|
|
27
|
+
SKILLS_DIR = os.path.join(HOME, ".claude", "skills")
|
|
28
|
+
OLLAMA_URL = "http://localhost:11434"
|
|
29
|
+
IS_WIN, IS_MAC = os.name == "nt", sys.platform == "darwin"
|
|
30
|
+
OS_NAME = "Windows" if IS_WIN else "macOS" if IS_MAC else "Linux"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _launcher_path(command):
|
|
34
|
+
return os.path.join(BIN_DIR, command + (".cmd" if IS_WIN else ""))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ollama_bin():
|
|
38
|
+
"""`ollama` on PATH, or where the Windows installer puts it (PATH isn't refreshed
|
|
39
|
+
for an already-running server)."""
|
|
40
|
+
found = shutil.which("ollama")
|
|
41
|
+
if found or not IS_WIN:
|
|
42
|
+
return found
|
|
43
|
+
p = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Ollama", "ollama.exe")
|
|
44
|
+
return p if os.path.exists(p) else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ollama_install_step():
|
|
48
|
+
"""How Ollama gets installed here: (step, command) or (None, manual instructions)."""
|
|
49
|
+
if IS_MAC and shutil.which("brew"):
|
|
50
|
+
return ({"do": "Install Ollama (free, open source) with Homebrew: brew install ollama",
|
|
51
|
+
"consent": True}, ["brew", "install", "ollama"])
|
|
52
|
+
if IS_WIN and shutil.which("winget"):
|
|
53
|
+
return ({"do": "Install Ollama (free, open source) with winget: "
|
|
54
|
+
"winget install -e --id Ollama.Ollama", "consent": True},
|
|
55
|
+
["winget", "install", "-e", "--id", "Ollama.Ollama",
|
|
56
|
+
"--accept-source-agreements", "--accept-package-agreements"])
|
|
57
|
+
if IS_MAC or IS_WIN:
|
|
58
|
+
return None, (f"Ollama isn't installed. Download it from https://ollama.com/download "
|
|
59
|
+
f"({OS_NAME}), install it, then click Add again.")
|
|
60
|
+
# Linux installer needs sudo, which a web page can't type for you
|
|
61
|
+
return None, ("Ollama isn't installed. Linux needs your sudo password, so run this in a "
|
|
62
|
+
"terminal, then click Add again:\n curl -fsSL https://ollama.com/install.sh | sh")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _path_hint():
|
|
66
|
+
if IS_WIN:
|
|
67
|
+
return (f'{BIN_DIR} is not on your PATH. Run in PowerShell: '
|
|
68
|
+
f'[Environment]::SetEnvironmentVariable("Path", $env:Path + ";{BIN_DIR}", "User") '
|
|
69
|
+
f'then open a new terminal.')
|
|
70
|
+
rc = "~/.zshrc" if IS_MAC else "~/.bashrc"
|
|
71
|
+
return f'{BIN_DIR} is not on your PATH. Add export PATH="$HOME/.local/bin:$PATH" to {rc}.'
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07")
|
|
75
|
+
JOBS = {}
|
|
76
|
+
_jobs_lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _job(fn, *args):
|
|
80
|
+
jid = uuid.uuid4().hex[:10]
|
|
81
|
+
job = {"id": jid, "state": "running", "log": [], "result": None}
|
|
82
|
+
with _jobs_lock:
|
|
83
|
+
JOBS[jid] = job
|
|
84
|
+
|
|
85
|
+
def log(line):
|
|
86
|
+
job["log"].append(line)
|
|
87
|
+
|
|
88
|
+
def run():
|
|
89
|
+
try:
|
|
90
|
+
job["result"] = fn(log, *args)
|
|
91
|
+
job["state"] = "done"
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
log(f"Failed: {exc}")
|
|
94
|
+
job["state"] = "failed"
|
|
95
|
+
threading.Thread(target=run, daemon=True).start()
|
|
96
|
+
return job
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def job_status(jid):
|
|
100
|
+
j = JOBS.get(jid)
|
|
101
|
+
return {"id": jid, "state": j["state"], "log": j["log"][-40:], "result": j["result"]} if j \
|
|
102
|
+
else {"id": jid, "state": "missing", "log": []}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _sh(cmd, log=None, timeout=None):
|
|
106
|
+
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
107
|
+
encoding="utf-8", errors="replace")
|
|
108
|
+
last = 0.0
|
|
109
|
+
for raw in p.stdout:
|
|
110
|
+
# terminal progress bars: drop escape codes and keep the last redraw on the line
|
|
111
|
+
parts = [ANSI.sub("", x).strip() for x in re.split(r"\r|\x1b\[\d*[AG]", raw)]
|
|
112
|
+
parts = [x for x in parts if x and x != "pulling manifest"]
|
|
113
|
+
if not parts:
|
|
114
|
+
continue
|
|
115
|
+
line = parts[-1]
|
|
116
|
+
# progress bars redraw constantly; keep the log readable
|
|
117
|
+
if log and line and (time.time() - last > 1.5 or "success" in line.lower()):
|
|
118
|
+
log(line[-160:])
|
|
119
|
+
last = time.time()
|
|
120
|
+
p.wait(timeout)
|
|
121
|
+
if p.returncode:
|
|
122
|
+
raise RuntimeError(f"{' '.join(cmd[:3])} exited with {p.returncode}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------ sync
|
|
126
|
+
def sync(log, source=DEFAULT_SOURCE):
|
|
127
|
+
"""Rebuild the warehouse into a temp file, then swap it in (the dashboard keeps serving)."""
|
|
128
|
+
tmp = DB_PATH + ".sync"
|
|
129
|
+
log("Reading Claude transcripts…")
|
|
130
|
+
Loader(db_path=tmp, source=source).build(verbose=False)
|
|
131
|
+
con = sqlite3.connect(tmp)
|
|
132
|
+
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
133
|
+
n = con.execute("SELECT COUNT(*) FROM requests").fetchone()[0]
|
|
134
|
+
con.close()
|
|
135
|
+
for suffix in ("-wal", "-shm"):
|
|
136
|
+
if os.path.exists(tmp + suffix):
|
|
137
|
+
os.remove(tmp + suffix)
|
|
138
|
+
log(f"Read {n:,} requests. Swapping in the new data…")
|
|
139
|
+
return {"tmp": tmp}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def finish_sync(tmp):
|
|
143
|
+
for suffix in ("-wal", "-shm"):
|
|
144
|
+
if os.path.exists(DB_PATH + suffix):
|
|
145
|
+
os.remove(DB_PATH + suffix)
|
|
146
|
+
os.replace(tmp, DB_PATH)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------ free models
|
|
150
|
+
def _catalog():
|
|
151
|
+
with open(os.path.join(ROOT, "config", "free_models.json")) as fh:
|
|
152
|
+
return json.load(fh)["models"]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _ram_gb():
|
|
156
|
+
if IS_WIN:
|
|
157
|
+
try:
|
|
158
|
+
import ctypes
|
|
159
|
+
|
|
160
|
+
class MS(ctypes.Structure):
|
|
161
|
+
_fields_ = [("len", ctypes.c_ulong), ("load", ctypes.c_ulong),
|
|
162
|
+
("total", ctypes.c_ulonglong), ("avail", ctypes.c_ulonglong),
|
|
163
|
+
("tp", ctypes.c_ulonglong), ("ap", ctypes.c_ulonglong),
|
|
164
|
+
("tv", ctypes.c_ulonglong), ("av", ctypes.c_ulonglong),
|
|
165
|
+
("ae", ctypes.c_ulonglong)]
|
|
166
|
+
m = MS()
|
|
167
|
+
m.len = ctypes.sizeof(MS)
|
|
168
|
+
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
|
|
169
|
+
return m.total / 2**30
|
|
170
|
+
except Exception:
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True).stdout
|
|
174
|
+
return int(out) / 2**30
|
|
175
|
+
except Exception:
|
|
176
|
+
try:
|
|
177
|
+
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 2**30
|
|
178
|
+
except Exception:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _free_disk_gb():
|
|
183
|
+
return shutil.disk_usage(HOME).free / 2**30
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _ollama_running():
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(OLLAMA_URL + "/api/tags", timeout=1.5) as r:
|
|
189
|
+
return [m["name"] for m in json.load(r).get("models", [])]
|
|
190
|
+
except Exception:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _on_path(d):
|
|
195
|
+
return d in os.environ.get("PATH", "").split(os.pathsep)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def free_models():
|
|
199
|
+
ram, disk = _ram_gb(), _free_disk_gb()
|
|
200
|
+
pulled = _ollama_running()
|
|
201
|
+
out = []
|
|
202
|
+
for m in _catalog():
|
|
203
|
+
launcher = _launcher_path(m["command"])
|
|
204
|
+
installed = os.path.exists(launcher)
|
|
205
|
+
if m["provider"] == "ollama":
|
|
206
|
+
have_model = pulled is not None and any(
|
|
207
|
+
n == m["model"] or n.split(":")[0] == m["model"] and ":" not in m["model"]
|
|
208
|
+
or n == m["model"] + ":latest" for n in pulled)
|
|
209
|
+
else:
|
|
210
|
+
have_model = os.path.exists(os.path.join(KEY_DIR, "openrouter.key"))
|
|
211
|
+
fits = not m.get("min_ram_gb") or ram is None or ram >= m["min_ram_gb"]
|
|
212
|
+
out.append({**m, "installed": installed and have_model, "launcher": launcher,
|
|
213
|
+
"fits_ram": fits, "ram_gb": round(ram or 0), "free_disk_gb": round(disk)})
|
|
214
|
+
return {"models": out, "ollama_installed": bool(_ollama_bin()), "os": OS_NAME,
|
|
215
|
+
"ollama_running": pulled is not None, "bin_on_path": _on_path(BIN_DIR),
|
|
216
|
+
"bin_dir": BIN_DIR,
|
|
217
|
+
"how_it_works": "Adding a model creates a command such as `claude-qwen` that opens "
|
|
218
|
+
"Claude Code talking to that model. Your normal `claude` command and "
|
|
219
|
+
"your Claude subscription are untouched."}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def free_model_plan(mid):
|
|
223
|
+
m = next((x for x in _catalog() if x["id"] == mid), None)
|
|
224
|
+
if not m:
|
|
225
|
+
raise KeyError(mid)
|
|
226
|
+
steps, needs = [], []
|
|
227
|
+
if m["provider"] == "ollama":
|
|
228
|
+
if not _ollama_bin():
|
|
229
|
+
step, how = _ollama_install_step()
|
|
230
|
+
if not step:
|
|
231
|
+
return {"model": m, "blocked": how}
|
|
232
|
+
steps.append(step)
|
|
233
|
+
if _ollama_running() is None:
|
|
234
|
+
steps.append({"do": "Start the Ollama background service", "consent": True})
|
|
235
|
+
ram = _ram_gb()
|
|
236
|
+
if ram and m.get("min_ram_gb") and ram < m["min_ram_gb"]:
|
|
237
|
+
steps.append({"do": f"Warning: this model wants {m['min_ram_gb']} GB RAM and this "
|
|
238
|
+
f"machine has {ram:.0f} GB. It may be very slow.", "warn": True})
|
|
239
|
+
if m.get("download_gb") and _free_disk_gb() < m["download_gb"] + 2:
|
|
240
|
+
return {"model": m, "blocked": f"Needs ~{m['download_gb']} GB free disk; only "
|
|
241
|
+
f"{_free_disk_gb():.0f} GB free."}
|
|
242
|
+
steps.append({"do": f"Download {m['model']} (~{m['download_gb']} GB) with ollama pull",
|
|
243
|
+
"consent": True})
|
|
244
|
+
else:
|
|
245
|
+
if not os.path.exists(os.path.join(KEY_DIR, "openrouter.key")):
|
|
246
|
+
needs.append({"field": "api_key", "label": "OpenRouter API key",
|
|
247
|
+
"url": "https://openrouter.ai/settings/keys",
|
|
248
|
+
"steps": [
|
|
249
|
+
"Open openrouter.ai and sign in (Google or GitHub works). It's free, no card needed.",
|
|
250
|
+
"Go to Settings → Keys (the button below opens it).",
|
|
251
|
+
"Click \"Create API Key\", name it e.g. claude-free, leave the credit limit empty.",
|
|
252
|
+
"Copy the key. It starts with sk-or-v1- and is shown only once.",
|
|
253
|
+
"Paste it below and click Add.",
|
|
254
|
+
],
|
|
255
|
+
"pattern": "^sk-or-",
|
|
256
|
+
"help": "Stored only on this machine, readable by you only. Free models "
|
|
257
|
+
"cost nothing; the key just identifies you for rate limits."})
|
|
258
|
+
steps.append({"do": f"Save the key to {os.path.join(KEY_DIR, 'openrouter.key')}"
|
|
259
|
+
+ ("" if IS_WIN else " (permissions 600)"),
|
|
260
|
+
"consent": False})
|
|
261
|
+
steps.append({"do": f"Create the command {m['command']} in {BIN_DIR}", "consent": False})
|
|
262
|
+
if not _on_path(BIN_DIR):
|
|
263
|
+
steps.append({"do": _path_hint(), "warn": True})
|
|
264
|
+
return {"model": m, "steps": steps, "needs": needs,
|
|
265
|
+
"consent_required": any(s.get("consent") for s in steps)}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
LAUNCHER_CMD = """@echo off
|
|
269
|
+
rem Created by Claude FinOps: Claude Code on {name}. Delete this file to remove it.
|
|
270
|
+
{key_line}set "ANTHROPIC_BASE_URL={base}"
|
|
271
|
+
set "ANTHROPIC_API_KEY="
|
|
272
|
+
claude --model "{model}" %*
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
LAUNCHER = """#!/usr/bin/env bash
|
|
276
|
+
# Created by Claude FinOps: Claude Code on {name}. Delete this file to remove it.
|
|
277
|
+
{key_line}export ANTHROPIC_BASE_URL="{base}"
|
|
278
|
+
export ANTHROPIC_API_KEY=""
|
|
279
|
+
{ctx_line}exec claude --model "{model}" "$@"
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def free_model_install(log, mid, consent, api_key=None):
|
|
284
|
+
plan = free_model_plan(mid)
|
|
285
|
+
if plan.get("blocked"):
|
|
286
|
+
raise RuntimeError(plan["blocked"])
|
|
287
|
+
if plan["consent_required"] and not consent:
|
|
288
|
+
raise RuntimeError("Not confirmed")
|
|
289
|
+
m = plan["model"]
|
|
290
|
+
if m["provider"] == "ollama":
|
|
291
|
+
if not _ollama_bin():
|
|
292
|
+
step, cmd = _ollama_install_step()
|
|
293
|
+
if not step:
|
|
294
|
+
raise RuntimeError(cmd)
|
|
295
|
+
log(f"Installing Ollama ({' '.join(cmd[:2])})…")
|
|
296
|
+
_sh(cmd, log)
|
|
297
|
+
ollama = _ollama_bin()
|
|
298
|
+
if not ollama:
|
|
299
|
+
raise RuntimeError("Ollama installed but not found yet. Open a new terminal, "
|
|
300
|
+
"then click Add again.")
|
|
301
|
+
if _ollama_running() is None:
|
|
302
|
+
log("Starting Ollama…")
|
|
303
|
+
if IS_MAC and shutil.which("brew") and subprocess.run(
|
|
304
|
+
["brew", "services", "start", "ollama"], capture_output=True).returncode == 0:
|
|
305
|
+
pass
|
|
306
|
+
elif not IS_WIN and shutil.which("systemctl") and subprocess.run(
|
|
307
|
+
["systemctl", "--user", "start", "ollama"], capture_output=True).returncode == 0:
|
|
308
|
+
pass
|
|
309
|
+
else:
|
|
310
|
+
kw = ({"creationflags": 0x00000008 | 0x00000200} if IS_WIN # detached, new group
|
|
311
|
+
else {"start_new_session": True})
|
|
312
|
+
subprocess.Popen([ollama, "serve"], stdout=subprocess.DEVNULL,
|
|
313
|
+
stderr=subprocess.DEVNULL, **kw)
|
|
314
|
+
for _ in range(30):
|
|
315
|
+
if _ollama_running() is not None:
|
|
316
|
+
break
|
|
317
|
+
time.sleep(1)
|
|
318
|
+
else:
|
|
319
|
+
raise RuntimeError("Ollama did not start")
|
|
320
|
+
log(f"Downloading {m['model']}… (this can take a while)")
|
|
321
|
+
_sh([ollama, "pull", m["model"]], log)
|
|
322
|
+
base = OLLAMA_URL
|
|
323
|
+
key_line = ('set "ANTHROPIC_AUTH_TOKEN=ollama"\n' if IS_WIN
|
|
324
|
+
else 'export ANTHROPIC_AUTH_TOKEN="ollama"\n')
|
|
325
|
+
ctx_line = ""
|
|
326
|
+
else:
|
|
327
|
+
keyf = os.path.join(KEY_DIR, "openrouter.key")
|
|
328
|
+
if api_key and not api_key.strip().startswith("sk-or-"):
|
|
329
|
+
raise RuntimeError("That doesn't look like an OpenRouter key (it should start with sk-or-)")
|
|
330
|
+
if api_key:
|
|
331
|
+
os.makedirs(KEY_DIR, mode=0o700, exist_ok=True)
|
|
332
|
+
with open(keyf, "w") as fh:
|
|
333
|
+
fh.write(api_key.strip())
|
|
334
|
+
if not IS_WIN:
|
|
335
|
+
os.chmod(keyf, 0o600)
|
|
336
|
+
log("Saved API key.")
|
|
337
|
+
if not os.path.exists(keyf):
|
|
338
|
+
raise RuntimeError("An OpenRouter API key is required")
|
|
339
|
+
base = "https://openrouter.ai/api"
|
|
340
|
+
key_line = (f'set /p ANTHROPIC_AUTH_TOKEN=<"{keyf}"\n' if IS_WIN
|
|
341
|
+
else f'export ANTHROPIC_AUTH_TOKEN="$(cat "{keyf}")"\n')
|
|
342
|
+
ctx_line = ""
|
|
343
|
+
os.makedirs(BIN_DIR, exist_ok=True)
|
|
344
|
+
path = _launcher_path(m["command"])
|
|
345
|
+
tpl = LAUNCHER_CMD if IS_WIN else LAUNCHER
|
|
346
|
+
with open(path, "w", newline="\r\n" if IS_WIN else "\n") as fh:
|
|
347
|
+
fh.write(tpl.format(name=m["name"], key_line=key_line, base=base,
|
|
348
|
+
ctx_line=ctx_line, model=m["model"]))
|
|
349
|
+
if not IS_WIN:
|
|
350
|
+
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
351
|
+
log(f"Done. Run `{m['command']}` in any project to use {m['name']}.")
|
|
352
|
+
return {"command": m["command"], "path": path}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def free_model_remove(mid):
|
|
356
|
+
m = next((x for x in _catalog() if x["id"] == mid), None)
|
|
357
|
+
p = _launcher_path(m["command"]) if m else None
|
|
358
|
+
if p and os.path.exists(p):
|
|
359
|
+
os.remove(p)
|
|
360
|
+
return {"ok": True, "note": "Launcher removed. Downloaded Ollama models stay; remove with "
|
|
361
|
+
"`ollama rm <model>` to free disk."}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ------------------------------------------------------------------ skill / MCP suggestions
|
|
365
|
+
# (id, name, what it gives, keyword regex over prompts + bash, `claude mcp add` args, input needed)
|
|
366
|
+
MCP_CATALOG = [
|
|
367
|
+
("atlassian", "Atlassian (Jira & Confluence)", "Read and update Jira tickets and Confluence pages without copy-paste.",
|
|
368
|
+
r"(?i:\b(jira|confluence)\b)|\b[A-Z]{2,6}-\d{2,6}\b",
|
|
369
|
+
["--transport", "sse", "atlassian", "https://mcp.atlassian.com/v1/sse"], None),
|
|
370
|
+
("github", "GitHub", "PRs, issues, reviews and CI status from inside Claude.",
|
|
371
|
+
r"\b(pull request|github|gh (pr|issue|run|api))\b",
|
|
372
|
+
["--transport", "http", "github", "https://api.githubcopilot.com/mcp/"], None),
|
|
373
|
+
("figma", "Figma", "Pull frames, tokens and specs straight from Figma designs.",
|
|
374
|
+
r"\b(figma)\b", ["--transport", "http", "figma", "https://mcp.figma.com/mcp"], None),
|
|
375
|
+
("playwright", "Playwright (browser)", "Open pages, click through flows and screenshot UI to check changes.",
|
|
376
|
+
r"\b(screenshot|browser|localhost:\d+|playwright|e2e|click (on|the))\b",
|
|
377
|
+
["playwright", "--", "npx", "-y", "@playwright/mcp@latest"], None),
|
|
378
|
+
("context7", "Context7 (library docs)", "Current docs for libraries and frameworks, instead of guessing APIs.",
|
|
379
|
+
r"\b(docs? for|latest version|api reference|how (do|to) (i )?use|deprecated)\b",
|
|
380
|
+
["--transport", "http", "context7", "https://mcp.context7.com/mcp"], None),
|
|
381
|
+
("sentry", "Sentry", "Pull real errors and stack traces while debugging.",
|
|
382
|
+
r"\b(sentry)\b", ["--transport", "http", "sentry", "https://mcp.sentry.dev/mcp"], None),
|
|
383
|
+
("notion", "Notion", "Read and write Notion pages and databases.",
|
|
384
|
+
r"\b(notion)\b", ["--transport", "http", "notion", "https://mcp.notion.com/mcp"], None),
|
|
385
|
+
("linear", "Linear", "Read and update Linear issues.",
|
|
386
|
+
r"(?i)linear\.app|\blinear (issue|ticket)s?\b", ["--transport", "sse", "linear", "https://mcp.linear.app/sse"], None),
|
|
387
|
+
("postgres", "Postgres", "Query your database schema and data directly.",
|
|
388
|
+
r"\b(postgres|psql|pg_dump|sql query|select \* from)\b",
|
|
389
|
+
["postgres", "--", "npx", "-y", "@modelcontextprotocol/server-postgres", "{conn}"],
|
|
390
|
+
{"field": "conn", "label": "Postgres connection string",
|
|
391
|
+
"help": "e.g. postgresql://readonly_user:pass@localhost:5432/mydb. Use a read-only user."}),
|
|
392
|
+
]
|
|
393
|
+
|
|
394
|
+
# normalised shell commands worth a skill: first word -> how many words identify the task
|
|
395
|
+
CMD_WORDS = {"npm": 3, "pnpm": 3, "yarn": 2, "npx": 2, "pytest": 1,
|
|
396
|
+
"make": 2, "docker": 2, "kubectl": 2, "gh": 3, "cargo": 2, "go": 2,
|
|
397
|
+
"mvn": 2, "gradle": 2, "./gradlew": 2, "terraform": 2, "bun": 3, "uv": 3}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _installed_mcp():
|
|
401
|
+
try:
|
|
402
|
+
with open(os.path.join(HOME, ".claude.json")) as fh:
|
|
403
|
+
cfg = json.load(fh)
|
|
404
|
+
except (OSError, ValueError):
|
|
405
|
+
cfg = {}
|
|
406
|
+
names = set(cfg.get("mcpServers") or {})
|
|
407
|
+
for p in (cfg.get("projects") or {}).values():
|
|
408
|
+
names |= set(p.get("mcpServers") or {})
|
|
409
|
+
return {n.lower() for n in names}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _norm_cmd(t):
|
|
413
|
+
t = (t or "").strip()
|
|
414
|
+
t = re.sub(r"^(cd [^&;]+&&\s*)+", "", t)
|
|
415
|
+
words = t.split()
|
|
416
|
+
if not words:
|
|
417
|
+
return None
|
|
418
|
+
n = CMD_WORDS.get(words[0])
|
|
419
|
+
if not n:
|
|
420
|
+
return None
|
|
421
|
+
words = [w for w in words[:n] if not w.startswith(("-", "<", "'", '"', "$"))
|
|
422
|
+
and "/" not in w[1:] and not re.search(r"[=&|>;0-9]", w)]
|
|
423
|
+
return " ".join(words) if len(words) == n else None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _slug(s):
|
|
427
|
+
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")[:40] or "task"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def suggestions(a):
|
|
431
|
+
"""Skills and MCP servers worth adding, from recurring work (actual transcripts).
|
|
432
|
+
|
|
433
|
+
Scans every prompt and shell command, so it's cached on the warehouse (reset by sync);
|
|
434
|
+
installed-state is re-checked on every call.
|
|
435
|
+
"""
|
|
436
|
+
if getattr(a, "_suggest", None) is None:
|
|
437
|
+
a._suggest = _suggestions(a)
|
|
438
|
+
out = a._suggest
|
|
439
|
+
installed = _installed_mcp()
|
|
440
|
+
existing = set(os.listdir(SKILLS_DIR)) if os.path.isdir(SKILLS_DIR) else set()
|
|
441
|
+
for m in out["mcp"]:
|
|
442
|
+
m["installed"] = m["installed"] or m["id"] in installed
|
|
443
|
+
for sk in out["skills"]:
|
|
444
|
+
sk["installed"] = sk["name"] in existing
|
|
445
|
+
return out
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _suggestions(a):
|
|
449
|
+
used_tools = {r["name"] for r in a.q("SELECT DISTINCT name FROM tool_calls WHERE name LIKE 'mcp__%'")}
|
|
450
|
+
installed = _installed_mcp()
|
|
451
|
+
corpus = a.q("""SELECT p.session_id, p.text, p.source, pj.name project FROM prompts p
|
|
452
|
+
JOIN projects pj ON pj.id=p.project_id WHERE p.text IS NOT NULL""")
|
|
453
|
+
bash = a.q("""SELECT t.session_id, t.target, pj.name project FROM tool_calls t
|
|
454
|
+
JOIN projects pj ON pj.id=t.project_id WHERE t.name='Bash'""")
|
|
455
|
+
|
|
456
|
+
mcps = []
|
|
457
|
+
for mid, name, what, rx, args, need in MCP_CATALOG:
|
|
458
|
+
r = re.compile(rx if mid == "atlassian" else "(?i)" + rx if not rx.startswith("(?i)") else rx)
|
|
459
|
+
sess, projs, samples = set(), defaultdict(int), []
|
|
460
|
+
for row in corpus:
|
|
461
|
+
m = r.search(row["text"] or "")
|
|
462
|
+
if m:
|
|
463
|
+
sess.add(row["session_id"])
|
|
464
|
+
projs[row["project"]] += 1
|
|
465
|
+
if len(samples) < 3:
|
|
466
|
+
samples.append(m.group(0))
|
|
467
|
+
for row in bash:
|
|
468
|
+
if r.search(row["target"] or ""):
|
|
469
|
+
sess.add(row["session_id"])
|
|
470
|
+
projs[row["project"]] += 1
|
|
471
|
+
if len(sess) < 3:
|
|
472
|
+
continue
|
|
473
|
+
have = mid in installed or any(mid in t.lower() for t in used_tools) or \
|
|
474
|
+
(mid == "playwright" and any("chrome" in t for t in used_tools))
|
|
475
|
+
mcps.append({"id": mid, "name": name, "what": what, "sessions": len(sess),
|
|
476
|
+
"projects": sorted(projs, key=projs.get, reverse=True)[:3],
|
|
477
|
+
"evidence": sorted(set(samples)), "installed": have,
|
|
478
|
+
"command": "claude mcp add -s user " + " ".join(
|
|
479
|
+
x if x != "{conn}" else "<connection-string>" for x in args),
|
|
480
|
+
"needs": [need] if need else []})
|
|
481
|
+
mcps.sort(key=lambda x: (x["installed"], -x["sessions"]))
|
|
482
|
+
|
|
483
|
+
# skills: shell commands repeated across sessions
|
|
484
|
+
cmds = defaultdict(lambda: {"sessions": set(), "runs": 0, "projects": defaultdict(int), "examples": []})
|
|
485
|
+
for row in bash:
|
|
486
|
+
k = _norm_cmd(row["target"])
|
|
487
|
+
if not k:
|
|
488
|
+
continue
|
|
489
|
+
c = cmds[k]
|
|
490
|
+
c["sessions"].add(row["session_id"])
|
|
491
|
+
c["runs"] += 1
|
|
492
|
+
c["projects"][row["project"]] += 1
|
|
493
|
+
if len(c["examples"]) < 3 and row["target"] not in c["examples"]:
|
|
494
|
+
c["examples"].append(row["target"][:200])
|
|
495
|
+
existing = set(os.listdir(SKILLS_DIR)) if os.path.isdir(SKILLS_DIR) else set()
|
|
496
|
+
skills = []
|
|
497
|
+
for k, c in cmds.items():
|
|
498
|
+
if len(c["sessions"]) < 3 or k in ("npm install", "yarn install", "pnpm install", "go get"):
|
|
499
|
+
continue
|
|
500
|
+
name = _slug(k)
|
|
501
|
+
top = sorted(c["projects"], key=c["projects"].get, reverse=True)
|
|
502
|
+
skills.append({"id": name, "name": name, "kind": "command", "trigger": k,
|
|
503
|
+
"sessions": len(c["sessions"]), "runs": c["runs"], "projects": top[:3],
|
|
504
|
+
"examples": c["examples"], "installed": name in existing,
|
|
505
|
+
"what": f"You ran `{k}` in {len(c['sessions'])} sessions. A skill records "
|
|
506
|
+
f"the exact command, flags and what to check in the output, so "
|
|
507
|
+
f"Claude runs it right first time instead of rediscovering it."})
|
|
508
|
+
|
|
509
|
+
# skills: prompts you keep retyping (same opening words across sessions)
|
|
510
|
+
opens = defaultdict(lambda: {"sessions": set(), "examples": [], "projects": defaultdict(int)})
|
|
511
|
+
for row in corpus:
|
|
512
|
+
words = re.findall(r"[a-z0-9']+", (row["text"] or "").lower())
|
|
513
|
+
# only what you typed yourself: sdk/system prompts are already automated
|
|
514
|
+
if row["source"] != "typed" or len(words) < 5 or (row["text"] or "").startswith(("<", "/")):
|
|
515
|
+
continue
|
|
516
|
+
k = " ".join(words[:5])
|
|
517
|
+
o = opens[k]
|
|
518
|
+
o["sessions"].add(row["session_id"])
|
|
519
|
+
o["projects"][row["project"]] += 1
|
|
520
|
+
if len(o["examples"]) < 2:
|
|
521
|
+
o["examples"].append(row["text"][:200])
|
|
522
|
+
for k, o in opens.items():
|
|
523
|
+
if len(o["sessions"]) < 4:
|
|
524
|
+
continue
|
|
525
|
+
name = _slug(" ".join(k.split()[:4]))
|
|
526
|
+
skills.append({"id": name, "name": name, "kind": "prompt", "trigger": k,
|
|
527
|
+
"sessions": len(o["sessions"]), "runs": len(o["sessions"]),
|
|
528
|
+
"projects": sorted(o["projects"], key=o["projects"].get, reverse=True)[:3],
|
|
529
|
+
"examples": o["examples"], "installed": name in existing,
|
|
530
|
+
"what": f"You started {len(o['sessions'])} sessions with \"{k}…\". Save it "
|
|
531
|
+
f"as a skill and trigger it with /{name}."})
|
|
532
|
+
skills.sort(key=lambda x: (x["installed"], -x["sessions"]))
|
|
533
|
+
return {"mcp": mcps[:12], "skills": skills[:15], "skills_dir": SKILLS_DIR,
|
|
534
|
+
"note": "Suggestions come from keywords in your prompts and the shell commands Claude "
|
|
535
|
+
"ran. Each shows the evidence; add only what you would actually reuse."}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def create_skill(s):
|
|
539
|
+
name = _slug(s["name"])
|
|
540
|
+
d = os.path.join(SKILLS_DIR, name)
|
|
541
|
+
if os.path.exists(d):
|
|
542
|
+
raise RuntimeError(f"{d} already exists")
|
|
543
|
+
ex = "\n".join(f" {e}" for e in s.get("examples", []))
|
|
544
|
+
if s.get("kind") == "command":
|
|
545
|
+
body = (f"---\nname: {name}\ndescription: Run `{s['trigger']}` the way this project expects. "
|
|
546
|
+
f"Use when running or verifying with `{s['trigger']}`.\n---\n\n"
|
|
547
|
+
f"# {name}\n\nRun this exactly as the project uses it:\n\n```sh\n{s['trigger']}\n```\n\n"
|
|
548
|
+
f"Seen in these projects: {', '.join(s.get('projects', []))}.\n\n"
|
|
549
|
+
f"Recent real invocations:\n\n{ex}\n\n"
|
|
550
|
+
f"## What to check\n\n- Read the tail of the output first; report failures with the "
|
|
551
|
+
f"first error, not the whole log.\n- Edit this section with the flags and gotchas "
|
|
552
|
+
f"you care about.\n")
|
|
553
|
+
else:
|
|
554
|
+
body = (f"---\nname: {name}\ndescription: Reusable request, started as \"{s['trigger']}…\".\n---\n\n"
|
|
555
|
+
f"# {name}\n\nYou asked for this in {s.get('sessions')} sessions. Examples:\n\n{ex}\n\n"
|
|
556
|
+
f"Edit these instructions into the exact steps and output you want every time.\n")
|
|
557
|
+
os.makedirs(d)
|
|
558
|
+
with open(os.path.join(d, "SKILL.md"), "w") as fh:
|
|
559
|
+
fh.write(body)
|
|
560
|
+
return {"ok": True, "path": os.path.join(d, "SKILL.md"), "use": f"/{name}"}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def add_mcp(mid, inputs):
|
|
564
|
+
item = next((x for x in MCP_CATALOG if x[0] == mid), None)
|
|
565
|
+
if not item:
|
|
566
|
+
raise KeyError(mid)
|
|
567
|
+
args = list(item[4])
|
|
568
|
+
if item[5]:
|
|
569
|
+
val = (inputs or {}).get(item[5]["field"], "").strip()
|
|
570
|
+
if not val:
|
|
571
|
+
raise RuntimeError(f"{item[5]['label']} is required")
|
|
572
|
+
args = [val if x == "{%s}" % item[5]["field"] else x for x in args]
|
|
573
|
+
exe = shutil.which("claude")
|
|
574
|
+
if not exe:
|
|
575
|
+
raise RuntimeError("`claude` CLI not found on PATH")
|
|
576
|
+
pre = [os.environ.get("COMSPEC", "cmd"), "/c", exe] if exe.lower().endswith((".cmd", ".bat")) else [exe]
|
|
577
|
+
r = subprocess.run(pre + ["mcp", "add", "-s", "user"] + args, capture_output=True, text=True,
|
|
578
|
+
encoding="utf-8", errors="replace", timeout=60)
|
|
579
|
+
if r.returncode:
|
|
580
|
+
raise RuntimeError((r.stderr or r.stdout).strip()[-400:])
|
|
581
|
+
return {"ok": True, "output": r.stdout.strip()[-400:],
|
|
582
|
+
"next": "Restart Claude Code. For servers that need sign-in, run /mcp and choose Authenticate."}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def free_model_test(log, mid):
|
|
586
|
+
"""Two checks: the model answers over the Anthropic API, then the launcher runs Claude Code end to end."""
|
|
587
|
+
m = next((x for x in _catalog() if x["id"] == mid), None)
|
|
588
|
+
if not m:
|
|
589
|
+
raise KeyError(mid)
|
|
590
|
+
launcher = _launcher_path(m["command"])
|
|
591
|
+
if not os.path.exists(launcher):
|
|
592
|
+
raise RuntimeError(f"{m['command']} isn't added yet")
|
|
593
|
+
if m["provider"] == "ollama":
|
|
594
|
+
if _ollama_running() is None:
|
|
595
|
+
raise RuntimeError("Ollama isn't running. Start the Ollama app (or run `ollama serve`).")
|
|
596
|
+
url, headers = OLLAMA_URL + "/v1/messages", {"x-api-key": "ollama"}
|
|
597
|
+
else:
|
|
598
|
+
with open(os.path.join(KEY_DIR, "openrouter.key")) as fh:
|
|
599
|
+
url, headers = "https://openrouter.ai/api/v1/messages", {"Authorization": "Bearer " + fh.read().strip()}
|
|
600
|
+
log(f"1/2 Asking {m['model']} directly…")
|
|
601
|
+
body = json.dumps({"model": m["model"], "max_tokens": 20,
|
|
602
|
+
"messages": [{"role": "user", "content": "Reply with exactly: OK"}]}).encode()
|
|
603
|
+
req = urllib.request.Request(url, body, {"content-type": "application/json",
|
|
604
|
+
"anthropic-version": "2023-06-01", **headers})
|
|
605
|
+
t0 = time.time()
|
|
606
|
+
try:
|
|
607
|
+
with urllib.request.urlopen(req, timeout=180) as r:
|
|
608
|
+
reply = "".join(b.get("text", "") for b in json.load(r).get("content", []))
|
|
609
|
+
except urllib.error.HTTPError as e:
|
|
610
|
+
raise RuntimeError(f"Model API returned {e.code}: {e.read().decode()[:300]}")
|
|
611
|
+
log(f" Model replied \"{reply.strip()[:60]}\" in {time.time() - t0:.1f}s ✓")
|
|
612
|
+
log(f"2/2 Running `{m['command']} -p` (real Claude Code session, may take a minute)…")
|
|
613
|
+
t0 = time.time()
|
|
614
|
+
cmd = ([os.environ.get("COMSPEC", "cmd"), "/c", launcher] if IS_WIN else [launcher])
|
|
615
|
+
r = subprocess.run(cmd + ["-p", "Reply with exactly: OK", "--max-turns", "1"],
|
|
616
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
617
|
+
timeout=300, cwd=HOME)
|
|
618
|
+
out = (r.stdout or r.stderr).strip()
|
|
619
|
+
if r.returncode:
|
|
620
|
+
raise RuntimeError(f"Claude Code exited {r.returncode}: {out[-300:]}")
|
|
621
|
+
log(f" Claude Code replied \"{out[:80]}\" in {time.time() - t0:.1f}s ✓")
|
|
622
|
+
log(f"Works. Run `{m['command']}` in any project.")
|
|
623
|
+
return {"ok": True, "command": m["command"]}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def compare(a, agents=None):
|
|
627
|
+
"""Each selected agent's priced models (with your usage) side by side; free models too
|
|
628
|
+
when Claude Code is selected, since they plug into Claude Code."""
|
|
629
|
+
with open(os.path.join(ROOT, "config", "model_compare.json")) as fh:
|
|
630
|
+
ratings = json.load(fh)["ratings"]
|
|
631
|
+
agents = agents or ["claude"]
|
|
632
|
+
prov = {"claude": "anthropic", "codex": "openai", "gemini": "google"}
|
|
633
|
+
want = {prov[x] for x in agents if x in prov}
|
|
634
|
+
where = {"anthropic": "Anthropic cloud", "openai": "OpenAI cloud", "google": "Google cloud"}
|
|
635
|
+
ph = ",".join("?" * len(agents))
|
|
636
|
+
used = {r["model"]: r for r in a.q(f"""SELECT model, COUNT(*) requests, SUM(est_cost_usd) cost,
|
|
637
|
+
SUM(billable_tokens) tokens FROM requests
|
|
638
|
+
WHERE agent IN ({ph}) GROUP BY model""", agents)}
|
|
639
|
+
rows = []
|
|
640
|
+
for mid, p in a.pricing.models.items():
|
|
641
|
+
pv = p.get("provider", "anthropic")
|
|
642
|
+
if p.get("tier") in (None, "none", "other") or pv not in want or mid.endswith("[1m]"):
|
|
643
|
+
continue
|
|
644
|
+
if pv == "anthropic" and mid not in ratings:
|
|
645
|
+
continue
|
|
646
|
+
u = used.get(mid) or {}
|
|
647
|
+
rt = ratings.get(mid) or {"tools": None, "reasoning": None, "multifile": None,
|
|
648
|
+
"speed": "—", "best_for": p.get("tier", "").capitalize() + " tier"}
|
|
649
|
+
rows.append({"id": mid, "name": p.get("display_name", mid), "kind": "claude" if pv == "anthropic" else pv,
|
|
650
|
+
"where": where.get(pv, pv), "price_in": p.get("input"), "price_out": p.get("output"),
|
|
651
|
+
"context": p.get("context_window"), "requests": u.get("requests") or 0,
|
|
652
|
+
"cost": u.get("cost") or 0, "tokens": u.get("tokens") or 0, "tier": p.get("tier"), **rt})
|
|
653
|
+
fm = free_models() if "claude" in agents else {"models": [], "os": None}
|
|
654
|
+
for m in fm["models"]:
|
|
655
|
+
if m["id"] not in ratings:
|
|
656
|
+
continue
|
|
657
|
+
rows.append({"id": m["id"], "name": m["name"], "kind": "free",
|
|
658
|
+
"where": "Your machine" if m["provider"] == "ollama" else "OpenRouter cloud",
|
|
659
|
+
"price_in": 0, "price_out": 0, "command": m["command"],
|
|
660
|
+
"installed": m["installed"], "fits": m["fits_ram"], "min_ram_gb": m.get("min_ram_gb"),
|
|
661
|
+
"download_gb": m.get("download_gb"), **ratings[m["id"]]})
|
|
662
|
+
rank = {"frontier": 0, "balanced": 1, "economy": 2}
|
|
663
|
+
score = lambda r: ((r["tools"] or 0) + (r["reasoning"] or 0) + (r["multifile"] or 0),
|
|
664
|
+
-rank.get(r.get("tier"), 3))
|
|
665
|
+
rows.sort(key=lambda r: score(r), reverse=True)
|
|
666
|
+
return {"rows": rows, "os": fm["os"], "ram_gb": fm["models"][0]["ram_gb"] if fm["models"] else None,
|
|
667
|
+
"agents": agents,
|
|
668
|
+
"note": "Ratings are judgement, not benchmarks; edit config/model_compare.json. "
|
|
669
|
+
"Unrated models show — until you add them there. Tool use matters most: coding "
|
|
670
|
+
"agents work by calling tools, and weak tool use means loops, broken edits "
|
|
671
|
+
"and early stops."}
|