chatdeck 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {chatdeck-0.2.0/src/chatdeck.egg-info → chatdeck-0.2.1}/PKG-INFO +1 -1
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/__init__.py +1 -1
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/hooks.py +3 -3
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/server.py +53 -13
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/static/index.html +2 -2
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/usage.py +8 -4
- {chatdeck-0.2.0 → chatdeck-0.2.1/src/chatdeck.egg-info}/PKG-INFO +1 -1
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck.egg-info/SOURCES.txt +4 -1
- {chatdeck-0.2.0 → chatdeck-0.2.1}/tests/test_chatdeck.py +3 -3
- chatdeck-0.2.1/tests/test_delete_safety.py +59 -0
- chatdeck-0.2.1/tests/test_hook_quoting.py +66 -0
- chatdeck-0.2.1/tests/test_usage_fixes.py +46 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/LICENSE +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/README.md +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/pyproject.toml +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/setup.cfg +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/__main__.py +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/autostart.py +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/cli.py +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/config.py +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/demo.py +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/desktop.py +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/static/logo-192.png +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/static/logo-512.png +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/static/logo.ico +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/static/logo.svg +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/terminals.py +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/transcripts.py +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/windows/bubble.ps1 +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/windows/focus_wt.ps1 +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck/windows/start.ps1 +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck.egg-info/dependency_links.txt +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck.egg-info/entry_points.txt +0 -0
- {chatdeck-0.2.0 → chatdeck-0.2.1}/src/chatdeck.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chatdeck
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A local dashboard for your Claude Code chats: what's running, what needs you, and what it costs.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Abdifatah09/chatdeck
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""chatdeck: a local dashboard for Claude Code chats."""
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.1"
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
their status line looks the same as before.
|
|
6
6
|
- Notification (`chatdeck hook waiting`): marks a chat as waiting on a permission prompt or a
|
|
7
7
|
question. The dashboard treats the mark as cleared once the transcript moves on."""
|
|
8
|
-
import json, os, re, shutil, subprocess, sys, time
|
|
8
|
+
import json, os, re, shlex, shutil, subprocess, sys, time
|
|
9
9
|
|
|
10
10
|
from . import config
|
|
11
11
|
|
|
@@ -55,9 +55,9 @@ def command():
|
|
|
55
55
|
"""How settings.json should call chatdeck: the absolute path of the installed command,
|
|
56
56
|
or this Python running the package from wherever it lives."""
|
|
57
57
|
exe = shutil.which("chatdeck")
|
|
58
|
-
if exe: return exe
|
|
58
|
+
if exe: return shlex.quote(exe)
|
|
59
59
|
src = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
60
|
-
return f"PYTHONPATH={src} {sys.executable} -m chatdeck"
|
|
60
|
+
return f"PYTHONPATH={shlex.quote(src)} {shlex.quote(sys.executable)} -m chatdeck"
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def _ours(cmd):
|
|
@@ -3,7 +3,7 @@ Lists running sessions (claude agents --json) and saved transcripts under
|
|
|
3
3
|
~/.claude/projects, and can view, search, rename, export and delete saved chats, stop
|
|
4
4
|
sessions, add and delete projects, and open chats in a terminal (see terminals.py). Binds 127.0.0.1 only; every API call needs
|
|
5
5
|
the per-run token embedded in the page (custom header => cross-site pages can't call it)."""
|
|
6
|
-
import json, os, glob, secrets, shlex, signal, subprocess, threading, time
|
|
6
|
+
import json, os, glob, secrets, shlex, signal, subprocess, sys, threading, time
|
|
7
7
|
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
8
8
|
from urllib.parse import urlparse, parse_qs, quote
|
|
9
9
|
|
|
@@ -28,19 +28,25 @@ INDEX = transcripts.Index(PROJECTS)
|
|
|
28
28
|
LIMITS = usage.LimitHistory(os.path.join(DATA, "limits-history.jsonl"))
|
|
29
29
|
|
|
30
30
|
_agents = (0.0, {})
|
|
31
|
+
_agents_ok = True # did the latest fetch work?
|
|
31
32
|
_agents_lock = threading.Lock()
|
|
32
33
|
def _fetch_agents():
|
|
33
|
-
|
|
34
|
+
"""Refresh the cache; True if that worked. A failed check (non-zero exit, timeout, no output) keeps the last good list."""
|
|
35
|
+
global _agents, _agents_ok
|
|
36
|
+
_agents_ok = False
|
|
34
37
|
try:
|
|
35
38
|
if config.DEMO:
|
|
36
39
|
with open(os.path.join(DATA, "demo-agents.json")) as f: out = f.read()
|
|
37
40
|
else:
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
r = subprocess.run(["claude", "agents", "--json"], capture_output=True, text=True, timeout=30)
|
|
42
|
+
if r.returncode != 0 or not r.stdout.strip(): return False
|
|
43
|
+
out = r.stdout
|
|
44
|
+
res = {s["sessionId"]: s for s in json.loads(out) if s.get("sessionId")}
|
|
40
45
|
with _agents_lock: _agents = (time.time(), res)
|
|
46
|
+
_agents_ok = True
|
|
47
|
+
return True
|
|
41
48
|
except Exception:
|
|
42
|
-
|
|
43
|
-
return _agents[1]
|
|
49
|
+
return False
|
|
44
50
|
|
|
45
51
|
def _agents_loop():
|
|
46
52
|
while True:
|
|
@@ -49,7 +55,16 @@ def _agents_loop():
|
|
|
49
55
|
def agents(fresh=False):
|
|
50
56
|
"""Running sessions. Normal reads use the list a background thread keeps fresh, so pages never wait;
|
|
51
57
|
destructive actions (delete/stop) ask for a fresh copy first."""
|
|
52
|
-
|
|
58
|
+
if fresh: _fetch_agents()
|
|
59
|
+
return _agents[1]
|
|
60
|
+
|
|
61
|
+
def agents_checked():
|
|
62
|
+
"""A fresh running-sessions list, or None if we couldn't get one. Destructive actions use this, so a
|
|
63
|
+
failed check can never look like 'nothing is running'."""
|
|
64
|
+
act = agents(fresh=True)
|
|
65
|
+
return act if _agents_ok else None
|
|
66
|
+
|
|
67
|
+
NO_CHECK = "couldn't check which chats are running"
|
|
53
68
|
|
|
54
69
|
def waiting(act, chats):
|
|
55
70
|
"""{sid: message} for running chats stuck on a permission prompt or question. A mark from the
|
|
@@ -177,7 +192,9 @@ def transcript(sid):
|
|
|
177
192
|
|
|
178
193
|
def delete(sids):
|
|
179
194
|
"""Delete saved chats. Their projects stay listed, empty, until the project itself is deleted."""
|
|
180
|
-
act =
|
|
195
|
+
act = agents_checked()
|
|
196
|
+
if act is None: return {"deleted": [], "refused": list(sids), "error": NO_CHECK}
|
|
197
|
+
done, refused = [], []; kept = added_projects(); keep = False
|
|
181
198
|
for sid in sids:
|
|
182
199
|
p = transcript(sid)
|
|
183
200
|
if not p or sid in act:
|
|
@@ -243,7 +260,9 @@ def compact(sid):
|
|
|
243
260
|
|
|
244
261
|
def stop(sid):
|
|
245
262
|
if config.DEMO: return DEMO_OFF
|
|
246
|
-
|
|
263
|
+
act = agents_checked()
|
|
264
|
+
if act is None: return {"ok": False, "error": NO_CHECK}
|
|
265
|
+
a = act.get(sid)
|
|
247
266
|
if not a or not a.get("pid"): return {"ok": False, "error": "not running"}
|
|
248
267
|
pid = int(a["pid"])
|
|
249
268
|
cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)], capture_output=True, text=True).stdout
|
|
@@ -289,7 +308,8 @@ def add_project(path):
|
|
|
289
308
|
def delete_project(proj):
|
|
290
309
|
"""Take a project off the list: delete its saved chats and forget it was added. Its folder is never touched.
|
|
291
310
|
Refused while any of its chats are running, so nothing is half-deleted."""
|
|
292
|
-
act =
|
|
311
|
+
act = agents_checked()
|
|
312
|
+
if act is None: return {"ok": False, "error": NO_CHECK}
|
|
293
313
|
chats = INDEX.all()
|
|
294
314
|
if any((chat_project(chats[sid][1], a.get("cwd")) if sid in chats else project(a.get("cwd"))) == proj for sid, a in act.items()):
|
|
295
315
|
return {"ok": False, "error": "it has running chats - stop them first"}
|
|
@@ -372,15 +392,35 @@ class H(BaseHTTPRequestHandler):
|
|
|
372
392
|
if self.path == "/api/open-running": return self.send(200, open_running(body.get("sid", "")))
|
|
373
393
|
self.send(404, {"error": "not found"})
|
|
374
394
|
|
|
395
|
+
CODE = os.path.dirname(os.path.abspath(__file__))
|
|
396
|
+
def _code_stamp():
|
|
397
|
+
"""When chatdeck's Python files last changed. The page is read from disk on every request, so only these go stale."""
|
|
398
|
+
return max((os.path.getmtime(p) for p in glob.glob(os.path.join(CODE, "*.py"))), default=0)
|
|
399
|
+
|
|
400
|
+
def _watch_code(httpd, restart):
|
|
401
|
+
"""Stop the server once its code changes (a git pull or reinstall), so serve() can start the new code.
|
|
402
|
+
Waits until the files stop changing, so it never starts on a half-finished pull."""
|
|
403
|
+
start = seen = _code_stamp()
|
|
404
|
+
while True:
|
|
405
|
+
time.sleep(2); now = _code_stamp()
|
|
406
|
+
if now != start and now == seen: restart.set(); httpd.shutdown(); return
|
|
407
|
+
seen = now
|
|
408
|
+
|
|
375
409
|
def serve(port):
|
|
376
|
-
"""Run the dashboard until stopped. Raises OSError if the port is taken."""
|
|
410
|
+
"""Run the dashboard until stopped. Raises OSError if the port is taken. Restarts itself when its code changes."""
|
|
377
411
|
httpd = ThreadingHTTPServer(("127.0.0.1", port), H)
|
|
378
412
|
os.makedirs(DATA, exist_ok=True)
|
|
379
413
|
pidfile = os.path.join(DATA, "server.pid")
|
|
380
414
|
with open(pidfile, "w") as f: f.write(str(os.getpid()))
|
|
381
415
|
threading.Thread(target=_agents_loop, daemon=True).start()
|
|
416
|
+
restart = threading.Event()
|
|
417
|
+
threading.Thread(target=_watch_code, args=(httpd, restart), daemon=True).start()
|
|
382
418
|
try:
|
|
383
419
|
httpd.serve_forever()
|
|
384
420
|
finally:
|
|
385
|
-
|
|
386
|
-
|
|
421
|
+
httpd.server_close()
|
|
422
|
+
if not restart.is_set():
|
|
423
|
+
try: os.remove(pidfile)
|
|
424
|
+
except OSError: pass
|
|
425
|
+
# same pid, so server.pid and the desktop bubble stay valid; the open page reloads when its old token is refused
|
|
426
|
+
os.execv(sys.executable, [sys.executable, "-m", "chatdeck", "serve", "--port", str(port)])
|
|
@@ -166,7 +166,7 @@ function renderUsage(U){if(!U)return;const t=U.today,w=U.week_tot,fresh=a=>a[0]+
|
|
|
166
166
|
else lim=`<div class="meta" style="margin-bottom:8px;white-space:normal">Plan limits appear here once a Claude session updates its status line (needs the status-line hook – see README).</div>`;
|
|
167
167
|
$("usage").innerHTML=`<div class="ugrid"><div class="tile"><div class="v">${k(fresh(t))}</div><div class="l">tokens today</div></div>
|
|
168
168
|
<div class="tile"><div class="v">${k(fresh(w))}</div><div class="l">last 7 days</div></div>
|
|
169
|
-
<div class="tile"><div class="v">${U.cost_24h?("$"+U.cost_24h.toFixed(2)):"–"}</div><div class="l">
|
|
169
|
+
<div class="tile"><div class="v">${U.cost_24h?("$"+U.cost_24h.toFixed(2)):"–"}</div><div class="l">total cost of chats active in 24h</div></div></div>
|
|
170
170
|
${lim}<div class="chart">${U.week.map((x,i)=>`<div class="${i===6?"today":""}" style="height:${Math.round(x[1]/max*100)}%" title="${k(x[1])} tokens"><span>${dn(x[0])}</span></div>`).join("")}</div>
|
|
171
171
|
<div class="proj">Top this week: ${U.by_project.map(([p,n])=>`<b>${esc(p)}</b> ${k(n)}`).join(" · ")||"–"}</div>`}
|
|
172
172
|
|
|
@@ -255,7 +255,7 @@ function onSearch(){render();clearTimeout(searchT);const q=$("q").value.trim();i
|
|
|
255
255
|
|
|
256
256
|
/* actions */
|
|
257
257
|
async function del(sids){if(!sids.length)return false;if(!confirm(`Delete ${sids.length} saved chat${sids.length>1?"s":""} permanently?`))return false;
|
|
258
|
-
const r=await getJ("/api/delete",{sids});sids.forEach(s=>sel.delete(s));toast(`Deleted ${r.deleted.length}`+(r.refused.length?` · ${r.refused.length} skipped (running)`:""));refresh(true);return true}
|
|
258
|
+
const r=await getJ("/api/delete",{sids});if(r.error){toast("Couldn't delete: "+r.error,4000);return false}sids.forEach(s=>sel.delete(s));toast(`Deleted ${r.deleted.length}`+(r.refused.length?` · ${r.refused.length} skipped (running)`:""));refresh(true);return true}
|
|
259
259
|
/* when no terminal could be opened, hand over the command to paste instead */
|
|
260
260
|
async function opened(r,msg){if(r.ok)return toast(msg);if(r.copy){try{await navigator.clipboard.writeText(r.copy);toast("Couldn't open a terminal – command copied, paste it into one")}catch{prompt("Run this in a terminal:",r.copy)}}else toast("Couldn't open: "+(r.error||"unknown error"))}
|
|
261
261
|
/* projects: add a folder by its path; delete takes the project off the list along with its saved chats */
|
|
@@ -19,8 +19,11 @@ class LimitHistory:
|
|
|
19
19
|
self.path = path; self.keep = keep; self.samples = []; self.lock = threading.Lock()
|
|
20
20
|
try:
|
|
21
21
|
with open(path) as f:
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
for l in f:
|
|
23
|
+
try: s = json.loads(l)
|
|
24
|
+
except ValueError: continue
|
|
25
|
+
if isinstance(s, dict) and all(k in s for k in "wtpr"): self.samples.append(s)
|
|
26
|
+
except OSError:
|
|
24
27
|
pass
|
|
25
28
|
|
|
26
29
|
def add(self, at, data):
|
|
@@ -58,12 +61,13 @@ class LimitHistory:
|
|
|
58
61
|
s = [x for x in self.samples if x["w"] == key]
|
|
59
62
|
if not s: return None
|
|
60
63
|
cur = s[-1]
|
|
64
|
+
if now >= cur["r"]: return {"eta": None, "rate": None} # that window has already reset
|
|
61
65
|
win = [x for x in s if x["r"] == cur["r"] and x["t"] >= cur["t"] - lookback]
|
|
62
66
|
if len(win) < 2 or cur["t"] - win[0]["t"] < 900: return {"eta": None, "rate": None}
|
|
63
67
|
rate = (cur["p"] - win[0]["p"]) / (cur["t"] - win[0]["t"]) # % per second
|
|
64
68
|
if rate <= 0: return {"eta": None, "rate": 0}
|
|
65
69
|
eta = cur["t"] + (100 - cur["p"]) / rate
|
|
66
|
-
return {"eta": eta if eta < cur["r"] else None, "rate": round(rate * 3600, 1)}
|
|
70
|
+
return {"eta": eta if now <= eta < cur["r"] else None, "rate": round(rate * 3600, 1)}
|
|
67
71
|
|
|
68
72
|
|
|
69
73
|
def summary(entries, status_dir, history, today=None, project=None):
|
|
@@ -84,7 +88,7 @@ def summary(entries, status_dir, history, today=None, project=None):
|
|
|
84
88
|
try:
|
|
85
89
|
with open(f) as fh: d = json.load(fh)
|
|
86
90
|
at = os.path.getmtime(f)
|
|
87
|
-
if at > now - 86400: cost += float((d.get("cost") or {}).get("total_cost_usd") or 0)
|
|
91
|
+
if at > now - 86400: cost += float((d.get("cost") or {}).get("total_cost_usd") or 0) # lifetime cost; status files keep no history
|
|
88
92
|
if d.get("rate_limits"):
|
|
89
93
|
limits = {"data": d["rate_limits"], "at": at}
|
|
90
94
|
history.add(at, d["rate_limits"])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chatdeck
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A local dashboard for your Claude Code chats: what's running, what needs you, and what it costs.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Abdifatah09/chatdeck
|
|
@@ -26,4 +26,7 @@ src/chatdeck/static/logo.svg
|
|
|
26
26
|
src/chatdeck/windows/bubble.ps1
|
|
27
27
|
src/chatdeck/windows/focus_wt.ps1
|
|
28
28
|
src/chatdeck/windows/start.ps1
|
|
29
|
-
tests/test_chatdeck.py
|
|
29
|
+
tests/test_chatdeck.py
|
|
30
|
+
tests/test_delete_safety.py
|
|
31
|
+
tests/test_hook_quoting.py
|
|
32
|
+
tests/test_usage_fixes.py
|
|
@@ -121,11 +121,11 @@ class ForecastTest(unittest.TestCase):
|
|
|
121
121
|
return h
|
|
122
122
|
|
|
123
123
|
def test_eta_from_pace(self):
|
|
124
|
-
f = self.hist([(0, 10), (1800, 20)]).forecast("five_hour") # 10% per 30 min
|
|
124
|
+
f = self.hist([(0, 10), (1800, 20)]).forecast("five_hour", T0 + 1800) # 10% per 30 min
|
|
125
125
|
self.assertEqual(f["rate"], 20.0); self.assertAlmostEqual(f["eta"], T0 + 1800 + 4 * 3600)
|
|
126
126
|
|
|
127
127
|
def test_no_eta_when_reset_comes_first(self):
|
|
128
|
-
f = self.hist([(0, 10), (1800, 20)], reset=5000).forecast("five_hour")
|
|
128
|
+
f = self.hist([(0, 10), (1800, 20)], reset=5000).forecast("five_hour", T0 + 1800)
|
|
129
129
|
self.assertIsNone(f["eta"]); self.assertGreater(f["rate"], 0)
|
|
130
130
|
|
|
131
131
|
def test_needs_enough_history_and_ignores_old_window(self):
|
|
@@ -133,7 +133,7 @@ class ForecastTest(unittest.TestCase):
|
|
|
133
133
|
h = self.hist([(0, 90)], reset=100)
|
|
134
134
|
h.add(T0 + 1000, {"five_hour": {"used_percentage": 1, "resets_at": T0 + 20000}})
|
|
135
135
|
h.add(T0 + 2000, {"five_hour": {"used_percentage": 1, "resets_at": T0 + 20000}})
|
|
136
|
-
self.assertEqual(h.forecast("five_hour")["rate"], 0)
|
|
136
|
+
self.assertEqual(h.forecast("five_hour", T0 + 2000)["rate"], 0)
|
|
137
137
|
|
|
138
138
|
def test_history_survives_restart(self):
|
|
139
139
|
h = self.hist([(0, 10), (1800, 20)])
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import json, os, subprocess, sys, tempfile, unittest, unittest.mock
|
|
2
|
+
|
|
3
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
4
|
+
os.environ["CHATDECK_DIR"] = tempfile.mkdtemp() # never touch the real ~/.chatdeck
|
|
5
|
+
from chatdeck import config, server
|
|
6
|
+
|
|
7
|
+
SID = "11111111-2222-3333-4444-555555555555"
|
|
8
|
+
RUNNING = json.dumps([{"sessionId": SID, "pid": 1}])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def done(code, out=""): return subprocess.CompletedProcess([], code, out, "")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeleteSafetyTest(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.dir = tempfile.mkdtemp(); os.makedirs(os.path.join(self.dir, "-w-proj"))
|
|
17
|
+
self.path = os.path.join(self.dir, "-w-proj", SID + ".jsonl")
|
|
18
|
+
with open(self.path, "w") as f: f.write("{}\n")
|
|
19
|
+
for k, v in [("PROJECTS", self.dir), ("INDEX", server.transcripts.Index(self.dir))]:
|
|
20
|
+
p = unittest.mock.patch.object(server, k, v); p.start(); self.addCleanup(p.stop)
|
|
21
|
+
p = unittest.mock.patch.object(config, "DEMO", False); p.start(); self.addCleanup(p.stop)
|
|
22
|
+
server._agents = (0.0, {})
|
|
23
|
+
|
|
24
|
+
def run_delete(self, **kw):
|
|
25
|
+
with unittest.mock.patch.object(server.subprocess, "run", **kw): return server.delete([SID])
|
|
26
|
+
|
|
27
|
+
def test_nonzero_exit_refuses(self):
|
|
28
|
+
r = self.run_delete(return_value=done(1))
|
|
29
|
+
self.assertEqual((r["deleted"], r["refused"]), ([], [SID])); self.assertIn("couldn't check", r["error"])
|
|
30
|
+
self.assertTrue(os.path.exists(self.path))
|
|
31
|
+
|
|
32
|
+
def test_empty_stdout_refuses(self):
|
|
33
|
+
r = self.run_delete(return_value=done(0, ""))
|
|
34
|
+
self.assertEqual(r["refused"], [SID]); self.assertTrue(os.path.exists(self.path))
|
|
35
|
+
|
|
36
|
+
def test_timeout_refuses(self):
|
|
37
|
+
r = self.run_delete(side_effect=subprocess.TimeoutExpired("claude", 30))
|
|
38
|
+
self.assertEqual(r["refused"], [SID]); self.assertTrue(os.path.exists(self.path))
|
|
39
|
+
|
|
40
|
+
def test_failure_keeps_last_good_cache(self):
|
|
41
|
+
self.run_delete(return_value=done(0, RUNNING)); self.run_delete(return_value=done(1))
|
|
42
|
+
self.assertIn(SID, server.agents())
|
|
43
|
+
|
|
44
|
+
def test_running_chat_refused(self):
|
|
45
|
+
r = self.run_delete(return_value=done(0, RUNNING))
|
|
46
|
+
self.assertEqual(r["refused"], [SID]); self.assertNotIn("error", r); self.assertTrue(os.path.exists(self.path))
|
|
47
|
+
|
|
48
|
+
def test_deleted_when_check_succeeds_and_not_running(self):
|
|
49
|
+
r = self.run_delete(return_value=done(0, "[]"))
|
|
50
|
+
self.assertEqual((r["deleted"], r["refused"]), ([SID], [])); self.assertFalse(os.path.exists(self.path))
|
|
51
|
+
|
|
52
|
+
def test_delete_project_and_stop_refuse_on_failed_check(self):
|
|
53
|
+
with unittest.mock.patch.object(server.subprocess, "run", return_value=done(1)):
|
|
54
|
+
self.assertFalse(server.delete_project("/w/proj")["ok"])
|
|
55
|
+
self.assertFalse(server.stop(SID)["ok"])
|
|
56
|
+
self.assertTrue(os.path.exists(self.path))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__": unittest.main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import json, os, shlex, sys, tempfile, unittest, unittest.mock
|
|
2
|
+
|
|
3
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
4
|
+
os.environ["CHATDECK_DIR"] = tempfile.mkdtemp() # never touch the real ~/.chatdeck
|
|
5
|
+
from chatdeck import config, hooks
|
|
6
|
+
|
|
7
|
+
SPACED = "/Users/jo smith/.local/bin/chatdeck"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QuotingTest(unittest.TestCase):
|
|
11
|
+
def setUp(self):
|
|
12
|
+
self.path = os.path.join(tempfile.mkdtemp(), "settings.json")
|
|
13
|
+
config.save({}) # other tests may have left a wrapped status line behind
|
|
14
|
+
|
|
15
|
+
def write(self, d):
|
|
16
|
+
with open(self.path, "w") as f: json.dump(d, f)
|
|
17
|
+
|
|
18
|
+
def read(self):
|
|
19
|
+
with open(self.path) as f: return json.load(f)
|
|
20
|
+
|
|
21
|
+
def old_install(self):
|
|
22
|
+
old = SPACED + " hook"
|
|
23
|
+
self.write({"statusLine": {"type": "command", "command": old + " statusline"},
|
|
24
|
+
"hooks": {"Notification": [{"matcher": hooks.WAITING_MATCHER,
|
|
25
|
+
"hooks": [{"type": "command", "command": old + " waiting"}]}]}})
|
|
26
|
+
|
|
27
|
+
def test_command_quotes_spaced_paths(self):
|
|
28
|
+
with unittest.mock.patch("shutil.which", return_value=SPACED):
|
|
29
|
+
self.assertEqual(shlex.split(hooks.command()), [SPACED])
|
|
30
|
+
with unittest.mock.patch("shutil.which", return_value=None), \
|
|
31
|
+
unittest.mock.patch.object(sys, "executable", "/opt/my py/python3"):
|
|
32
|
+
parts = shlex.split(hooks.command())
|
|
33
|
+
self.assertTrue(parts[0].startswith("PYTHONPATH="))
|
|
34
|
+
self.assertEqual(parts[1:], ["/opt/my py/python3", "-m", "chatdeck"])
|
|
35
|
+
|
|
36
|
+
def test_normal_path_unchanged(self):
|
|
37
|
+
with unittest.mock.patch("shutil.which", return_value="/usr/local/bin/chatdeck"):
|
|
38
|
+
self.assertEqual(hooks.command(), "/usr/local/bin/chatdeck")
|
|
39
|
+
|
|
40
|
+
def test_install_writes_quoted_commands_and_uninstall_removes(self):
|
|
41
|
+
with unittest.mock.patch("shutil.which", return_value=SPACED):
|
|
42
|
+
hooks.install(self.path)
|
|
43
|
+
s = self.read()
|
|
44
|
+
self.assertEqual(shlex.split(s["statusLine"]["command"]), [SPACED, "hook", "statusline"])
|
|
45
|
+
self.assertEqual(shlex.split(s["hooks"]["Notification"][0]["hooks"][0]["command"]), [SPACED, "hook", "waiting"])
|
|
46
|
+
hooks.uninstall(self.path)
|
|
47
|
+
self.assertEqual(self.read(), {})
|
|
48
|
+
|
|
49
|
+
def test_reinstall_over_old_unquoted_install(self):
|
|
50
|
+
self.old_install()
|
|
51
|
+
with unittest.mock.patch("shutil.which", return_value=SPACED):
|
|
52
|
+
hooks.install(self.path)
|
|
53
|
+
s = self.read()
|
|
54
|
+
self.assertEqual(shlex.split(s["statusLine"]["command"]), [SPACED, "hook", "statusline"])
|
|
55
|
+
self.assertEqual(len(s["hooks"]["Notification"]), 1)
|
|
56
|
+
self.assertEqual(len(s["hooks"]["Notification"][0]["hooks"]), 1)
|
|
57
|
+
self.assertNotIn("statusline_command", config.load()) # old install wasn't mistaken for the user's own
|
|
58
|
+
|
|
59
|
+
def test_uninstall_removes_old_unquoted_install(self):
|
|
60
|
+
self.old_install()
|
|
61
|
+
hooks.uninstall(self.path)
|
|
62
|
+
self.assertEqual(self.read(), {})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
unittest.main()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import json, os, sys, tempfile, time, unittest
|
|
2
|
+
|
|
3
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
4
|
+
os.environ["CHATDECK_DIR"] = tempfile.mkdtemp() # never touch the real ~/.chatdeck
|
|
5
|
+
from chatdeck import usage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
T = time.time() - 3600
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sample(t, p, r=10000, w="five_hour"): return {"t": t, "w": w, "p": p, "r": r}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HistoryTest(unittest.TestCase):
|
|
15
|
+
def path(self, *lines):
|
|
16
|
+
p = os.path.join(tempfile.mkdtemp(), "h.jsonl")
|
|
17
|
+
with open(p, "w") as f: f.write("\n".join(lines) + "\n")
|
|
18
|
+
return p
|
|
19
|
+
|
|
20
|
+
def test_corrupt_and_malformed_lines_skipped(self):
|
|
21
|
+
good = [sample(T + 1, 5), sample(T + 2, 6)]
|
|
22
|
+
h = usage.LimitHistory(self.path(json.dumps(good[0]), '{"t": 3, "w": "five', '{"w": "five_hour"}', "[1]", json.dumps(good[1])))
|
|
23
|
+
self.assertEqual(h.samples, good)
|
|
24
|
+
h.add(T + 5, {"five_hour": {"used_percentage": 7, "resets_at": 10000}}) # no KeyError
|
|
25
|
+
self.assertEqual(len(h.samples), 3)
|
|
26
|
+
|
|
27
|
+
def test_stale_window_has_no_eta(self):
|
|
28
|
+
h = usage.LimitHistory(self.path()); h.samples = [sample(0, 10), sample(3600, 20)]
|
|
29
|
+
self.assertEqual(h.forecast("five_hour", now=5000)["rate"], 10.0)
|
|
30
|
+
self.assertEqual(h.forecast("five_hour", now=10000), {"eta": None, "rate": None})
|
|
31
|
+
self.assertIsNone(h.forecast("five_hour", now=9000)["eta"]) # eta (36000) is past the reset
|
|
32
|
+
h.samples = [sample(0, 10, 99999), sample(3600, 20, 99999)]
|
|
33
|
+
self.assertEqual(h.forecast("five_hour", now=3700)["eta"], 3600 + 80 * 360)
|
|
34
|
+
self.assertIsNone(h.forecast("five_hour", now=3600 + 80 * 360 + 1)["eta"])
|
|
35
|
+
|
|
36
|
+
def test_cost_counts_only_recent_status_files(self):
|
|
37
|
+
d = tempfile.mkdtemp()
|
|
38
|
+
for name, cost, age in (("a", 1.5, 100), ("b", 2.0, 3 * 86400)):
|
|
39
|
+
p = os.path.join(d, name + ".json")
|
|
40
|
+
with open(p, "w") as f: json.dump({"cost": {"total_cost_usd": cost}}, f)
|
|
41
|
+
os.utime(p, (time.time() - age,) * 2)
|
|
42
|
+
h = usage.LimitHistory(os.path.join(d, "h.jsonl"))
|
|
43
|
+
self.assertEqual(usage.summary([], d, h)["cost_24h"], 1.5)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__": unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|