chatdeck 0.1.0__py3-none-any.whl
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/__init__.py +2 -0
- chatdeck/__main__.py +3 -0
- chatdeck/autostart.py +91 -0
- chatdeck/cli.py +152 -0
- chatdeck/config.py +34 -0
- chatdeck/demo.py +141 -0
- chatdeck/desktop.py +93 -0
- chatdeck/hooks.py +131 -0
- chatdeck/server.py +345 -0
- chatdeck/static/index.html +342 -0
- chatdeck/static/logo-192.png +0 -0
- chatdeck/static/logo-512.png +0 -0
- chatdeck/static/logo.ico +0 -0
- chatdeck/static/logo.svg +13 -0
- chatdeck/terminals.py +218 -0
- chatdeck/transcripts.py +216 -0
- chatdeck/usage.py +96 -0
- chatdeck/windows/bubble.ps1 +303 -0
- chatdeck/windows/focus_wt.ps1 +68 -0
- chatdeck/windows/start.ps1 +35 -0
- chatdeck-0.1.0.dist-info/METADATA +164 -0
- chatdeck-0.1.0.dist-info/RECORD +26 -0
- chatdeck-0.1.0.dist-info/WHEEL +5 -0
- chatdeck-0.1.0.dist-info/entry_points.txt +2 -0
- chatdeck-0.1.0.dist-info/licenses/LICENSE +21 -0
- chatdeck-0.1.0.dist-info/top_level.txt +1 -0
chatdeck/__init__.py
ADDED
chatdeck/__main__.py
ADDED
chatdeck/autostart.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Starting chatdeck at login.
|
|
2
|
+
|
|
3
|
+
WSL: a "chatdeck" shortcut in the Windows Startup folder runs the launcher (server in WSL, then
|
|
4
|
+
the corner bubble and tray icon); another in the Start menu also opens the dashboard.
|
|
5
|
+
macOS: a LaunchAgent. Linux: a systemd user service, or an XDG autostart entry without systemd."""
|
|
6
|
+
import os, shutil, subprocess
|
|
7
|
+
|
|
8
|
+
from . import config, desktop, hooks, terminals
|
|
9
|
+
|
|
10
|
+
LABEL = "io.github.chatdeck"
|
|
11
|
+
MAC_PLIST = os.path.expanduser(f"~/Library/LaunchAgents/{LABEL}.plist")
|
|
12
|
+
SYSTEMD_UNIT = os.path.expanduser("~/.config/systemd/user/chatdeck.service")
|
|
13
|
+
XDG_ENTRY = os.path.expanduser("~/.config/autostart/chatdeck.desktop")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _serve_args(port):
|
|
17
|
+
"""The command line that runs the server, as a list (for launchd/systemd)."""
|
|
18
|
+
exe = shutil.which("chatdeck")
|
|
19
|
+
if exe: return [exe, "serve", "--port", str(port)]
|
|
20
|
+
return None # running from a clone without the installed command: use the shell form
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _shell_cmd(port):
|
|
24
|
+
return f"{hooks.command()} serve --port {port}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def enable(port, hotkey=""):
|
|
28
|
+
k = terminals.kind()
|
|
29
|
+
if k == "wsl":
|
|
30
|
+
distro = os.environ.get("WSL_DISTRO_NAME") or "Ubuntu"
|
|
31
|
+
folder = desktop.install_windows_files(port, distro, hooks.command())
|
|
32
|
+
f = desktop.win_folders()
|
|
33
|
+
desktop.shortcut(f["startup"] + "\\chatdeck.lnk", folder)
|
|
34
|
+
desktop.shortcut(f["programs"] + "\\chatdeck.lnk", folder, "-Open", hotkey)
|
|
35
|
+
return [f"Windows Startup: starts chatdeck and the corner button at login",
|
|
36
|
+
f"Start menu: \"chatdeck\" opens the dashboard" + (f" ({hotkey})" if hotkey else "")]
|
|
37
|
+
if k == "mac":
|
|
38
|
+
args = _serve_args(port) or ["/bin/sh", "-lc", _shell_cmd(port)]
|
|
39
|
+
items = "".join(f"<string>{a}</string>" for a in args)
|
|
40
|
+
os.makedirs(os.path.dirname(MAC_PLIST), exist_ok=True)
|
|
41
|
+
with open(MAC_PLIST, "w") as fh:
|
|
42
|
+
fh.write(f'<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
|
|
43
|
+
f'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>'
|
|
44
|
+
f"<key>Label</key><string>{LABEL}</string><key>ProgramArguments</key><array>{items}</array>"
|
|
45
|
+
f"<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>"
|
|
46
|
+
f"<key>StandardErrorPath</key><string>{config.DATA}/server.log</string></dict></plist>\n")
|
|
47
|
+
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}/{LABEL}"], capture_output=True)
|
|
48
|
+
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", MAC_PLIST], capture_output=True)
|
|
49
|
+
return ["LaunchAgent: chatdeck starts at login"]
|
|
50
|
+
if k == "linux":
|
|
51
|
+
if shutil.which("systemctl") and subprocess.run(["systemctl", "--user", "is-system-running"], capture_output=True).returncode in (0, 1):
|
|
52
|
+
exec_line = " ".join(_serve_args(port) or ["/bin/sh", "-lc", f"'{_shell_cmd(port)}'"])
|
|
53
|
+
os.makedirs(os.path.dirname(SYSTEMD_UNIT), exist_ok=True)
|
|
54
|
+
with open(SYSTEMD_UNIT, "w") as fh:
|
|
55
|
+
fh.write(f"[Unit]\nDescription=chatdeck dashboard for Claude Code\n\n[Service]\nExecStart={exec_line}\n"
|
|
56
|
+
f"Restart=on-failure\n\n[Install]\nWantedBy=default.target\n")
|
|
57
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
|
|
58
|
+
subprocess.run(["systemctl", "--user", "enable", "--now", "chatdeck.service"], capture_output=True)
|
|
59
|
+
return ["systemd user service: chatdeck starts at login"]
|
|
60
|
+
os.makedirs(os.path.dirname(XDG_ENTRY), exist_ok=True)
|
|
61
|
+
with open(XDG_ENTRY, "w") as fh:
|
|
62
|
+
fh.write(f"[Desktop Entry]\nType=Application\nName=chatdeck\nExec=/bin/sh -lc '{_shell_cmd(port)}'\n"
|
|
63
|
+
f"X-GNOME-Autostart-enabled=true\n")
|
|
64
|
+
return ["desktop autostart entry: chatdeck starts when you log in"]
|
|
65
|
+
raise SystemExit("Starting at login isn't supported on this system.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def disable():
|
|
69
|
+
k = terminals.kind(); done = []
|
|
70
|
+
if k == "wsl":
|
|
71
|
+
f = desktop.win_folders()
|
|
72
|
+
for lnk in (f["startup"] + "\\chatdeck.lnk", f["programs"] + "\\chatdeck.lnk"):
|
|
73
|
+
p = desktop.to_wsl(lnk)
|
|
74
|
+
if os.path.exists(p): os.remove(p); done.append(f"removed {lnk}")
|
|
75
|
+
elif k == "mac":
|
|
76
|
+
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}/{LABEL}"], capture_output=True)
|
|
77
|
+
if os.path.exists(MAC_PLIST): os.remove(MAC_PLIST); done.append("removed the LaunchAgent")
|
|
78
|
+
elif k == "linux":
|
|
79
|
+
if os.path.exists(SYSTEMD_UNIT):
|
|
80
|
+
subprocess.run(["systemctl", "--user", "disable", "--now", "chatdeck.service"], capture_output=True)
|
|
81
|
+
os.remove(SYSTEMD_UNIT); done.append("removed the systemd user service")
|
|
82
|
+
if os.path.exists(XDG_ENTRY): os.remove(XDG_ENTRY); done.append("removed the autostart entry")
|
|
83
|
+
return done
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def status():
|
|
87
|
+
k = terminals.kind()
|
|
88
|
+
if k == "wsl": return os.path.exists(desktop.to_wsl(desktop.win_folders()["startup"] + "\\chatdeck.lnk"))
|
|
89
|
+
if k == "mac": return os.path.exists(MAC_PLIST)
|
|
90
|
+
if k == "linux": return os.path.exists(SYSTEMD_UNIT) or os.path.exists(XDG_ENTRY)
|
|
91
|
+
return False
|
chatdeck/cli.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""chatdeck command line.
|
|
2
|
+
|
|
3
|
+
chatdeck start the dashboard (in the background) and open it
|
|
4
|
+
chatdeck serve run the server in the foreground (what autostart uses)
|
|
5
|
+
chatdeck stop stop a background server
|
|
6
|
+
chatdeck setup add the Claude Code hooks for plan limits and "waiting for you"
|
|
7
|
+
chatdeck autostart on|off start chatdeck at login, or stop doing that
|
|
8
|
+
chatdeck uninstall remove the hooks and autostart
|
|
9
|
+
chatdeck demo try it with made-up chats (nothing real is read or changed)
|
|
10
|
+
chatdeck bubble Windows (WSL): start the corner button and tray icon
|
|
11
|
+
chatdeck hook ... used by Claude Code itself"""
|
|
12
|
+
import argparse, os, signal, subprocess, sys, time, urllib.request
|
|
13
|
+
|
|
14
|
+
from . import __version__, config, hooks
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _up(port):
|
|
18
|
+
try:
|
|
19
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) as r:
|
|
20
|
+
return b"<title>chatdeck</title>" in r.read(4096)
|
|
21
|
+
except OSError:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _start_background(port):
|
|
26
|
+
os.makedirs(config.DATA, exist_ok=True)
|
|
27
|
+
log = open(os.path.join(config.DATA, "server.log"), "ab")
|
|
28
|
+
env = dict(os.environ, PYTHONPATH=os.pathsep.join(filter(None, [os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
29
|
+
os.environ.get("PYTHONPATH")])))
|
|
30
|
+
subprocess.Popen([sys.executable, "-m", "chatdeck", "serve", "--port", str(port)], stdout=log, stderr=log,
|
|
31
|
+
stdin=subprocess.DEVNULL, start_new_session=True, env=env)
|
|
32
|
+
for _ in range(40):
|
|
33
|
+
if _up(port): return True
|
|
34
|
+
time.sleep(0.25)
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_open(a):
|
|
39
|
+
from . import desktop
|
|
40
|
+
if not _up(a.port):
|
|
41
|
+
print(f"Starting chatdeck on http://127.0.0.1:{a.port} ...")
|
|
42
|
+
if not _start_background(a.port):
|
|
43
|
+
sys.exit(f"chatdeck didn't start - see {os.path.join(config.DATA, 'server.log')}")
|
|
44
|
+
if not a.no_open: desktop.open_dashboard(f"http://127.0.0.1:{a.port}/")
|
|
45
|
+
print(f"chatdeck is running at http://127.0.0.1:{a.port}")
|
|
46
|
+
if not os.path.exists(os.path.join(config.DATA, "status")) and "statusline_command" not in config.load():
|
|
47
|
+
print("Tip: run `chatdeck setup` so it can show your plan limits and which chats are waiting for you.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_serve(a):
|
|
51
|
+
from . import server
|
|
52
|
+
if _up(a.port): sys.exit(f"chatdeck is already running on port {a.port}.")
|
|
53
|
+
print(f"chatdeck on http://127.0.0.1:{a.port} (Ctrl+C to stop)", flush=True)
|
|
54
|
+
try:
|
|
55
|
+
server.serve(a.port)
|
|
56
|
+
except OSError as e:
|
|
57
|
+
sys.exit(f"Can't use port {a.port}: {e.strerror}. Pick another with --port.")
|
|
58
|
+
except KeyboardInterrupt:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_stop(a):
|
|
63
|
+
try:
|
|
64
|
+
with open(os.path.join(config.DATA, "server.pid")) as f: pid = int(f.read())
|
|
65
|
+
os.kill(pid, signal.SIGTERM); print("Stopped chatdeck.")
|
|
66
|
+
except (OSError, ValueError):
|
|
67
|
+
print("chatdeck isn't running in the background.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_setup(a):
|
|
71
|
+
for line in hooks.install(): print(" " + line)
|
|
72
|
+
print(f"Done. Your previous settings were backed up to {os.path.join(config.DATA, 'backups')}.")
|
|
73
|
+
print("New and restarted Claude Code sessions pick this up.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_uninstall(a):
|
|
77
|
+
done = hooks.uninstall()
|
|
78
|
+
try:
|
|
79
|
+
from . import autostart
|
|
80
|
+
done += autostart.disable()
|
|
81
|
+
except SystemExit:
|
|
82
|
+
pass
|
|
83
|
+
for line in done or ["nothing to remove"]: print(" " + line)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_autostart(a):
|
|
87
|
+
from . import autostart
|
|
88
|
+
if a.state == "on":
|
|
89
|
+
cfg = config.load(); cfg["port"] = a.port; config.save(cfg)
|
|
90
|
+
for line in autostart.enable(a.port, a.hotkey): print(" " + line)
|
|
91
|
+
elif a.state == "off":
|
|
92
|
+
for line in autostart.disable() or ["it wasn't set to start at login"]: print(" " + line)
|
|
93
|
+
else:
|
|
94
|
+
print("on" if autostart.status() else "off")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_bubble(a):
|
|
98
|
+
from . import desktop, terminals
|
|
99
|
+
if terminals.kind() != "wsl": sys.exit("The corner button is Windows-only for now (run chatdeck from WSL).")
|
|
100
|
+
desktop.start_bubble(a.port)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_demo(a):
|
|
104
|
+
import shutil, tempfile
|
|
105
|
+
from . import demo, desktop
|
|
106
|
+
root = tempfile.mkdtemp(prefix="chatdeck-demo-")
|
|
107
|
+
try:
|
|
108
|
+
env = dict(os.environ, HOME=root, CHATDECK_DIR=demo.build(root), CHATDECK_DEMO="1",
|
|
109
|
+
PYTHONPATH=os.pathsep.join(filter(None, [os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
110
|
+
os.environ.get("PYTHONPATH")])))
|
|
111
|
+
srv = subprocess.Popen([sys.executable, "-m", "chatdeck", "serve", "--port", str(a.port)], env=env)
|
|
112
|
+
url = f"http://127.0.0.1:{a.port}/"
|
|
113
|
+
for _ in range(40):
|
|
114
|
+
if _up(a.port) or srv.poll() is not None: break
|
|
115
|
+
time.sleep(0.25)
|
|
116
|
+
if not a.no_open and srv.poll() is None: desktop.open_dashboard(url)
|
|
117
|
+
srv.wait()
|
|
118
|
+
except KeyboardInterrupt:
|
|
119
|
+
srv.wait()
|
|
120
|
+
finally:
|
|
121
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_hook(a):
|
|
125
|
+
if a.name == "statusline": hooks.statusline()
|
|
126
|
+
elif a.name == "waiting": hooks.waiting()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv=None):
|
|
130
|
+
p = argparse.ArgumentParser(prog="chatdeck", description="A local dashboard for your Claude Code chats.")
|
|
131
|
+
p.add_argument("--version", action="version", version=f"chatdeck {__version__}")
|
|
132
|
+
sub = p.add_subparsers(dest="cmd")
|
|
133
|
+
def port_arg(sp): sp.add_argument("--port", type=int, default=config.port(), help=f"default {config.port()}")
|
|
134
|
+
o = sub.add_parser("open", help="start the dashboard if needed and open it (the default)")
|
|
135
|
+
port_arg(o); o.add_argument("--no-open", action="store_true", help="start it but don't open a window")
|
|
136
|
+
port_arg(sub.add_parser("serve", help="run the server in the foreground"))
|
|
137
|
+
sub.add_parser("stop", help="stop a background server")
|
|
138
|
+
sub.add_parser("setup", help="add the Claude Code hooks chatdeck uses")
|
|
139
|
+
sub.add_parser("uninstall", help="remove the hooks and autostart")
|
|
140
|
+
s = sub.add_parser("autostart", help="start chatdeck at login: on, off or status")
|
|
141
|
+
s.add_argument("state", choices=["on", "off", "status"]); port_arg(s)
|
|
142
|
+
s.add_argument("--hotkey", default="", help='Windows: shortcut key for the Start menu entry, e.g. "Ctrl+Alt+A"')
|
|
143
|
+
d = sub.add_parser("demo", help="try chatdeck with made-up chats")
|
|
144
|
+
d.add_argument("--port", type=int, default=47199, help="default 47199"); d.add_argument("--no-open", action="store_true")
|
|
145
|
+
port_arg(sub.add_parser("bubble", help="Windows: start the corner button and tray icon"))
|
|
146
|
+
h = sub.add_parser("hook", help=argparse.SUPPRESS); h.add_argument("name", choices=["statusline", "waiting"])
|
|
147
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
148
|
+
if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help", "--version")):
|
|
149
|
+
argv.insert(0, "open") # plain `chatdeck` (with or without options) starts and opens the dashboard
|
|
150
|
+
a = p.parse_args(argv)
|
|
151
|
+
{"open": cmd_open, "serve": cmd_serve, "stop": cmd_stop, "setup": cmd_setup, "uninstall": cmd_uninstall,
|
|
152
|
+
"autostart": cmd_autostart, "demo": cmd_demo, "bubble": cmd_bubble, "hook": cmd_hook}[a.cmd](a)
|
chatdeck/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Where chatdeck keeps its data, and its settings (~/.chatdeck/config.json).
|
|
2
|
+
Environment variables (CHATDECK_PORT, CHATDECK_TERMINAL, CHATDECK_VDLIB, CHATDECK_DIR) override the file."""
|
|
3
|
+
import json, os
|
|
4
|
+
|
|
5
|
+
HOME = os.path.expanduser("~")
|
|
6
|
+
DATA = os.environ.get("CHATDECK_DIR") or os.path.join(HOME, ".chatdeck")
|
|
7
|
+
FILE = os.path.join(DATA, "config.json")
|
|
8
|
+
DEFAULT_PORT = 47123
|
|
9
|
+
CLAUDE_SETTINGS = os.path.join(HOME, ".claude", "settings.json")
|
|
10
|
+
DEMO = bool(os.environ.get("CHATDECK_DEMO")) # `chatdeck demo`: made-up chats, nothing outside them is touched
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load():
|
|
14
|
+
try:
|
|
15
|
+
with open(FILE) as f: return json.load(f)
|
|
16
|
+
except (OSError, ValueError):
|
|
17
|
+
return {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save(cfg):
|
|
21
|
+
os.makedirs(DATA, exist_ok=True)
|
|
22
|
+
tmp = FILE + ".tmp"
|
|
23
|
+
with open(tmp, "w") as f: json.dump(cfg, f, indent=2)
|
|
24
|
+
os.replace(tmp, FILE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get(key, default=None):
|
|
28
|
+
env = os.environ.get("CHATDECK_" + key.upper())
|
|
29
|
+
if env: return env
|
|
30
|
+
return load().get(key, default)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def port():
|
|
34
|
+
return int(get("port") or DEFAULT_PORT)
|
chatdeck/demo.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Demo mode: a made-up home folder with Claude Code chats in a few projects, some of them
|
|
2
|
+
"running", so chatdeck can be tried (and screenshotted) without real chats.
|
|
3
|
+
|
|
4
|
+
`chatdeck demo` builds it in a temp folder and runs the server with HOME pointing there."""
|
|
5
|
+
import datetime, json, os, random, time, uuid
|
|
6
|
+
|
|
7
|
+
# (project folder, title, days ago, context tokens, cost, running status, turns)
|
|
8
|
+
# A turn is (you, tool name, tool input, tool output, Claude's reply); tool parts may be None.
|
|
9
|
+
CHATS = [
|
|
10
|
+
("code/webshop", "Add Stripe checkout to the cart page", 0, 142_000, 3.84, "busy", [
|
|
11
|
+
("Add Stripe checkout to the cart page. Use their hosted checkout, not Elements.",
|
|
12
|
+
"Read", {"file_path": "src/pages/cart.tsx"}, "export default function Cart() { ... }",
|
|
13
|
+
"The cart page renders line items from `useCart()`. I'll add a server route that creates a Checkout "
|
|
14
|
+
"Session and a **Pay** button that redirects to it."),
|
|
15
|
+
("Sounds good. Keep the test keys in .env.local",
|
|
16
|
+
"Edit", {"file_path": "src/server/checkout.ts"}, "File updated",
|
|
17
|
+
"Added `POST /api/checkout`. It reads `STRIPE_SECRET_KEY` from `.env.local` and returns the session URL."),
|
|
18
|
+
("Now handle the success and cancel pages",
|
|
19
|
+
"Bash", {"command": "npm test -- checkout"}, "PASS tests/checkout.test.ts (4 tests)",
|
|
20
|
+
"Both pages are in, and the checkout tests pass. Running the webhook tests next."),
|
|
21
|
+
]),
|
|
22
|
+
("code/webshop/.claude/worktrees/dark-mode", "Dark mode for the storefront", 1, 64_000, 1.12, None, [
|
|
23
|
+
("Add a dark mode that follows the system setting",
|
|
24
|
+
"Grep", {"pattern": "--color-bg"}, "src/styles/tokens.css:3",
|
|
25
|
+
"Colors are already tokens in `tokens.css`, so dark mode is one `prefers-color-scheme` block."),
|
|
26
|
+
("Do it, and add a toggle in the footer", None, None, None,
|
|
27
|
+
"Done: the toggle stores the choice in localStorage and falls back to the system setting."),
|
|
28
|
+
]),
|
|
29
|
+
("code/webshop", "Fix flaky cart total test", 2, 38_000, 0.61, None, [
|
|
30
|
+
("cart total test fails about 1 in 10 runs", "Bash", {"command": "npm test -- cart --repeat 20"},
|
|
31
|
+
"2 failed, 18 passed", "It rounds floats. Prices should be integer cents - switching the cart to cents."),
|
|
32
|
+
]),
|
|
33
|
+
("code/api-server", "Rate-limit the public API", 0, 96_000, 2.07, "waiting", [
|
|
34
|
+
("Add rate limiting to the public API, 100 requests a minute per key",
|
|
35
|
+
"Read", {"file_path": "app/middleware.py"}, "def auth_middleware(request): ...",
|
|
36
|
+
"I'll add a token-bucket limiter after auth, backed by Redis so it works across workers."),
|
|
37
|
+
("Yes, and return Retry-After", "Bash", {"command": "pip install redis"}, None,
|
|
38
|
+
"I need to install the `redis` package to continue."),
|
|
39
|
+
]),
|
|
40
|
+
("code/api-server", "Migrate users table to Postgres 16", 1, 210_000, 5.26, None, [
|
|
41
|
+
("We're moving from Postgres 12 to 16. Plan the users table migration",
|
|
42
|
+
"Bash", {"command": "pg_dump --schema-only -t users"}, "CREATE TABLE users (...)",
|
|
43
|
+
"The table uses `md5` passwords and an old `citext` version. Plan: upgrade extensions, switch to "
|
|
44
|
+
"scram-sha-256, then run `pg_upgrade --link`."),
|
|
45
|
+
("Write it up as a runbook", None, None, None, "Wrote `docs/runbooks/postgres-16.md` with rollback steps."),
|
|
46
|
+
]),
|
|
47
|
+
("code/api-server", "Why is /orders slow?", 4, 51_000, 0.94, None, [
|
|
48
|
+
("GET /orders takes 3 seconds for big accounts", "Bash", {"command": "EXPLAIN ANALYZE SELECT ..."},
|
|
49
|
+
"Seq Scan on order_items (cost=0.00..48211.00)", "It scans `order_items`. An index on `order_id` fixes it: 3s to 40ms."),
|
|
50
|
+
]),
|
|
51
|
+
("code/mobile-app", "Push notifications on Android", 0, 188_000, 4.41, "idle", [
|
|
52
|
+
("Set up push notifications on Android with FCM", "Read", {"file_path": "android/app/build.gradle"}, "...",
|
|
53
|
+
"Added the Firebase plugin and a `NotificationService`. You'll need to drop `google-services.json` into `android/app`."),
|
|
54
|
+
("Done, it's there. Test it", "Bash", {"command": "npx react-native run-android"}, "BUILD SUCCESSFUL",
|
|
55
|
+
"A test notification arrives on the emulator, both in the foreground and the background."),
|
|
56
|
+
]),
|
|
57
|
+
("code/mobile-app", "Upgrade to React Native 0.76", 5, 120_000, 2.88, None, [
|
|
58
|
+
("Upgrade us to React Native 0.76", "Bash", {"command": "npx react-native upgrade 0.76"}, "Upgrade complete",
|
|
59
|
+
"Upgraded. Two libraries needed new versions; the app builds on iOS and Android."),
|
|
60
|
+
]),
|
|
61
|
+
("code/blog", "Write a post about SQLite in production", 3, 44_000, 0.73, None, [
|
|
62
|
+
("Draft a blog post: why we run SQLite in production", None, None, None,
|
|
63
|
+
"Here's a draft with three parts: why it's enough, how we back it up (Litestream), and where it isn't enough."),
|
|
64
|
+
]),
|
|
65
|
+
("dotfiles", "Tidy up my zsh config", 6, 22_000, 0.31, None, [
|
|
66
|
+
("My zsh takes 2 seconds to start", "Bash", {"command": "zsh -xv -i -c exit 2>&1 | tail"}, "nvm.sh ...",
|
|
67
|
+
"Most of it is `nvm`. Loading it lazily brings startup to 0.2 seconds."),
|
|
68
|
+
]),
|
|
69
|
+
("", "Plan my week", 0, 9_000, 0.08, None, [
|
|
70
|
+
("Help me plan this week: ship checkout, the Postgres move, and the blog post", None, None, None,
|
|
71
|
+
"Checkout Monday to Tuesday, the Postgres runbook review Wednesday, the migration Thursday night, "
|
|
72
|
+
"and the blog post Friday."),
|
|
73
|
+
]),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _line(f, d): f.write(json.dumps(d) + "\n")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build(root):
|
|
81
|
+
"""Write the demo home into root (an empty folder). Returns the CHATDECK_DIR to use."""
|
|
82
|
+
rnd = random.Random(7); now = time.time()
|
|
83
|
+
projects = os.path.join(root, ".claude", "projects"); data = os.path.join(root, ".chatdeck")
|
|
84
|
+
agents = []
|
|
85
|
+
for i, (folder, title, ago, ctx, cost, run, turns) in enumerate(CHATS):
|
|
86
|
+
run_name = {"Add Stripe checkout to the cart page": "stripe-checkout", "Rate-limit the public API": "api-rate-limit",
|
|
87
|
+
"Push notifications on Android": "android-push"}.get(title)
|
|
88
|
+
cwd = os.path.join(root, folder) if folder else root
|
|
89
|
+
os.makedirs(cwd, exist_ok=True)
|
|
90
|
+
sid = str(uuid.UUID(int=rnd.getrandbits(128)))
|
|
91
|
+
pdir = os.path.join(projects, cwd.replace("/", "-").replace(".", "-")); os.makedirs(pdir, exist_ok=True)
|
|
92
|
+
path = os.path.join(pdir, sid + ".jsonl")
|
|
93
|
+
end = now - ago * 86400 - rnd.randint(5, 50) * 60 if not run else now - rnd.randint(1, 4) * 60
|
|
94
|
+
t = end - len(turns) * 600
|
|
95
|
+
|
|
96
|
+
def base(kind):
|
|
97
|
+
return {"type": kind, "timestamp": datetime.datetime.fromtimestamp(t, datetime.timezone.utc).isoformat()
|
|
98
|
+
.replace("+00:00", "Z"), "cwd": cwd, "sessionId": sid, "uuid": str(uuid.uuid4())}
|
|
99
|
+
with open(path, "w") as f:
|
|
100
|
+
for n, (you, tool, inp, out, reply) in enumerate(turns):
|
|
101
|
+
_line(f, dict(base("user"), message={"role": "user", "content": you}))
|
|
102
|
+
last = n == len(turns) - 1
|
|
103
|
+
usage = {"input_tokens": rnd.randint(200, 2000), "output_tokens": rnd.randint(2000, 9000) * (1 + 6 * (ago > 0)), # older chats stand for a whole day's work
|
|
104
|
+
"cache_creation_input_tokens": rnd.randint(min(8000, ctx // 4), min(30000, ctx // 2))}
|
|
105
|
+
usage["cache_read_input_tokens"] = max(0, (ctx if last else ctx * (n + 1) // len(turns))
|
|
106
|
+
- usage["input_tokens"] - usage["cache_creation_input_tokens"])
|
|
107
|
+
content = [{"type": "text", "text": reply}]
|
|
108
|
+
if tool: content.append({"type": "tool_use", "id": f"tu{i}{n}", "name": tool, "input": inp})
|
|
109
|
+
_line(f, dict(base("assistant"), message={"id": f"msg_{i}_{n}", "role": "assistant", "content": content, "usage": usage}))
|
|
110
|
+
if tool and out:
|
|
111
|
+
t += 60
|
|
112
|
+
_line(f, dict(base("user"), message={"role": "user", "content": [
|
|
113
|
+
{"type": "tool_result", "tool_use_id": f"tu{i}{n}", "content": out}]}))
|
|
114
|
+
t += 540
|
|
115
|
+
_line(f, {"type": "ai-title", "aiTitle": title, "sessionId": sid})
|
|
116
|
+
_line(f, {"type": "cost-state", "totalCostUSD": cost, "sessionId": sid})
|
|
117
|
+
os.utime(path, (end, end))
|
|
118
|
+
if run:
|
|
119
|
+
agents.append({"pid": 0, "cwd": cwd, "kind": "interactive", "startedAt": int((end - 3600 * (i + 1)) * 1000),
|
|
120
|
+
"sessionId": sid, "name": run_name,
|
|
121
|
+
"status": "busy" if run == "busy" else "idle"})
|
|
122
|
+
if run == "waiting":
|
|
123
|
+
os.makedirs(os.path.join(data, "waiting"), exist_ok=True)
|
|
124
|
+
with open(os.path.join(data, "waiting", sid + ".json"), "w") as f:
|
|
125
|
+
json.dump({"at": now, "message": "Claude needs your permission to use Bash"}, f)
|
|
126
|
+
# an empty chat, for the cleanup menu
|
|
127
|
+
empty = os.path.join(projects, os.listdir(projects)[0], str(uuid.UUID(int=rnd.getrandbits(128))) + ".jsonl")
|
|
128
|
+
open(empty, "w").close(); os.utime(empty, (now - 9 * 86400, now - 9 * 86400))
|
|
129
|
+
|
|
130
|
+
os.makedirs(os.path.join(data, "status"), exist_ok=True)
|
|
131
|
+
limits = {"five_hour": {"used_percentage": 62, "resets_at": int(now + 2 * 3600)},
|
|
132
|
+
"seven_day": {"used_percentage": 41, "resets_at": int(now + 3 * 86400)}}
|
|
133
|
+
with open(os.path.join(data, "status", agents[0]["sessionId"] + ".json"), "w") as f:
|
|
134
|
+
json.dump({"rate_limits": limits, "cost": {"total_cost_usd": 12.4}}, f)
|
|
135
|
+
with open(os.path.join(data, "limits-history.jsonl"), "w") as f: # an hour of history, for the forecast
|
|
136
|
+
for k in range(7):
|
|
137
|
+
at = now - 3600 + k * 600
|
|
138
|
+
_line(f, {"t": at, "w": "five_hour", "p": 40 + k * 3, "r": limits["five_hour"]["resets_at"]})
|
|
139
|
+
_line(f, {"t": at, "w": "seven_day", "p": 39 + k * 0.3, "r": limits["seven_day"]["resets_at"]})
|
|
140
|
+
with open(os.path.join(data, "demo-agents.json"), "w") as f: json.dump(agents, f)
|
|
141
|
+
return data
|
chatdeck/desktop.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Opening the dashboard window, and the Windows side of things when running under WSL
|
|
2
|
+
(the corner bubble, shortcuts in the Start menu and Startup folder)."""
|
|
3
|
+
import filecmp, os, shutil, subprocess, webbrowser
|
|
4
|
+
|
|
5
|
+
from . import terminals
|
|
6
|
+
|
|
7
|
+
PKG = os.path.dirname(os.path.abspath(__file__))
|
|
8
|
+
WIN_FILES = ("bubble.ps1", "start.ps1")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# --- opening the dashboard ------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
def _app_browser():
|
|
14
|
+
"""A Chromium browser that can open the dashboard as its own app window."""
|
|
15
|
+
k = terminals.kind()
|
|
16
|
+
if k == "wsl":
|
|
17
|
+
for p in ("/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
|
|
18
|
+
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|
19
|
+
"/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
|
|
20
|
+
"/mnt/c/Program Files/Microsoft/Edge/Application/msedge.exe"):
|
|
21
|
+
if os.path.exists(p): return [p]
|
|
22
|
+
elif k == "mac":
|
|
23
|
+
for app in ("Google Chrome", "Microsoft Edge", "Brave Browser", "Chromium"):
|
|
24
|
+
if os.path.isdir(f"/Applications/{app}.app"): return ["open", "-na", app, "--args"]
|
|
25
|
+
else:
|
|
26
|
+
for n in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"):
|
|
27
|
+
if shutil.which(n): return [shutil.which(n)]
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def open_dashboard(url):
|
|
32
|
+
b = _app_browser()
|
|
33
|
+
if b:
|
|
34
|
+
try:
|
|
35
|
+
subprocess.Popen([*b, f"--app={url}", "--window-size=520,900"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
36
|
+
start_new_session=True, cwd="/mnt/c" if b[0].startswith("/mnt/") else None)
|
|
37
|
+
return
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
webbrowser.open(url)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- Windows (from WSL) ---------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def _ps(script, timeout=30):
|
|
46
|
+
ps = terminals._win_exe("powershell.exe", "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
|
|
47
|
+
r = subprocess.run([ps, "-NoProfile", "-Command", script], capture_output=True, text=True, cwd="/mnt/c", timeout=timeout)
|
|
48
|
+
return r.stdout.strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def win_folders():
|
|
52
|
+
"""{"local": ..., "startup": ..., "programs": ...} as Windows paths."""
|
|
53
|
+
out = _ps('[Environment]::GetFolderPath("LocalApplicationData"); [Environment]::GetFolderPath("Startup"); '
|
|
54
|
+
'[Environment]::GetFolderPath("Programs")')
|
|
55
|
+
local, startup, programs = out.splitlines()[:3]
|
|
56
|
+
return {"local": local, "startup": startup, "programs": programs}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def to_wsl(win_path):
|
|
60
|
+
return subprocess.run(["wslpath", "-u", win_path], capture_output=True, text=True).stdout.strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def install_windows_files(port, distro, cmd):
|
|
64
|
+
"""Copy the bubble and launcher into %LOCALAPPDATA%\\chatdeck; returns that folder (Windows path)."""
|
|
65
|
+
folder = win_folders()["local"] + "\\chatdeck"
|
|
66
|
+
dest = to_wsl(folder); os.makedirs(dest, exist_ok=True)
|
|
67
|
+
shutil.copy(os.path.join(PKG, "windows", "bubble.ps1"), dest)
|
|
68
|
+
shutil.copy(os.path.join(PKG, "static", "logo.ico"), dest)
|
|
69
|
+
with open(os.path.join(PKG, "windows", "start.ps1")) as f: start = f.read()
|
|
70
|
+
start = (start.replace("__PORT__", str(port)).replace("__DISTRO__", distro).replace("__CMD__", cmd))
|
|
71
|
+
with open(os.path.join(dest, "start.ps1"), "w", newline="\r\n") as f: f.write(start)
|
|
72
|
+
return folder
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def shortcut(lnk, target_folder, args="", hotkey=""):
|
|
76
|
+
"""Create a .lnk that runs start.ps1 (hidden) from target_folder."""
|
|
77
|
+
script = (f'$s = (New-Object -ComObject WScript.Shell).CreateShortcut("{lnk}"); '
|
|
78
|
+
f'$s.TargetPath = "$env:SystemRoot\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; '
|
|
79
|
+
f'$s.Arguments = \'-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "{target_folder}\\start.ps1" {args}\'; '
|
|
80
|
+
f'$s.WindowStyle = 7; $s.IconLocation = "{target_folder}\\logo.ico"; $s.Description = "chatdeck"; '
|
|
81
|
+
+ (f'$s.Hotkey = "{hotkey}"; ' if hotkey else "") + '$s.Save()')
|
|
82
|
+
_ps(script)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def start_bubble(port):
|
|
86
|
+
"""Start the corner bubble (and tray icon). A second start just shows the running one."""
|
|
87
|
+
folder = win_folders()["local"] + "\\chatdeck"
|
|
88
|
+
src, dst = os.path.join(PKG, "windows", "bubble.ps1"), os.path.join(to_wsl(folder), "bubble.ps1")
|
|
89
|
+
if not os.path.exists(dst) or not filecmp.cmp(src, dst, shallow=False): # also after chatdeck is updated
|
|
90
|
+
os.makedirs(to_wsl(folder), exist_ok=True)
|
|
91
|
+
shutil.copy(src, dst)
|
|
92
|
+
_ps(f'Start-Process powershell.exe -WindowStyle Hidden -ArgumentList "-NoProfile","-WindowStyle","Hidden",'
|
|
93
|
+
f'"-ExecutionPolicy","Bypass","-File","{folder}\\bubble.ps1","-Port","{port}"')
|
chatdeck/hooks.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Claude Code hooks chatdeck relies on, and installing them into ~/.claude/settings.json.
|
|
2
|
+
|
|
3
|
+
- Status line (`chatdeck hook statusline`): saves each session's status payload (plan limits,
|
|
4
|
+
cost) for the usage panel, then runs the status line the person already had, if any, so
|
|
5
|
+
their status line looks the same as before.
|
|
6
|
+
- Notification (`chatdeck hook waiting`): marks a chat as waiting on a permission prompt or a
|
|
7
|
+
question. The dashboard treats the mark as cleared once the transcript moves on."""
|
|
8
|
+
import json, os, re, shutil, subprocess, sys, time
|
|
9
|
+
|
|
10
|
+
from . import config
|
|
11
|
+
|
|
12
|
+
WAITING_MATCHER = "permission_prompt|elicitation_dialog"
|
|
13
|
+
LEGACY = ("hooks/waiting.py",) # hook commands from before chatdeck was a package
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _write_atomic(path, data):
|
|
17
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
18
|
+
with open(path + ".tmp", "w") as f: f.write(data)
|
|
19
|
+
os.replace(path + ".tmp", path)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --- running the hooks ----------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def statusline(stdin=sys.stdin, stdout=sys.stdout):
|
|
25
|
+
raw = stdin.read()
|
|
26
|
+
try:
|
|
27
|
+
sid = json.loads(raw).get("session_id", "")
|
|
28
|
+
if re.fullmatch(r"[0-9a-f-]{36}", sid):
|
|
29
|
+
_write_atomic(os.path.join(config.DATA, "status", sid + ".json"), raw)
|
|
30
|
+
except (ValueError, AttributeError, OSError):
|
|
31
|
+
pass
|
|
32
|
+
wrapped = config.load().get("statusline_command")
|
|
33
|
+
if wrapped:
|
|
34
|
+
try:
|
|
35
|
+
r = subprocess.run(wrapped, shell=True, input=raw, capture_output=True, text=True, timeout=10)
|
|
36
|
+
stdout.write(r.stdout)
|
|
37
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def waiting(stdin=sys.stdin):
|
|
42
|
+
try:
|
|
43
|
+
d = json.load(stdin)
|
|
44
|
+
sid = d.get("session_id", "")
|
|
45
|
+
if re.fullmatch(r"[0-9a-f-]{36}", sid):
|
|
46
|
+
_write_atomic(os.path.join(config.DATA, "waiting", sid + ".json"),
|
|
47
|
+
json.dumps({"at": time.time(), "message": d.get("message", ""), "type": d.get("notification_type", "")}))
|
|
48
|
+
except (ValueError, AttributeError, OSError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --- installing them ------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def command():
|
|
55
|
+
"""How settings.json should call chatdeck: the absolute path of the installed command,
|
|
56
|
+
or this Python running the package from wherever it lives."""
|
|
57
|
+
exe = shutil.which("chatdeck")
|
|
58
|
+
if exe: return exe
|
|
59
|
+
src = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
60
|
+
return f"PYTHONPATH={src} {sys.executable} -m chatdeck"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ours(cmd):
|
|
64
|
+
return " hook statusline" in cmd or " hook waiting" in cmd or any(l in cmd for l in LEGACY)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load(path):
|
|
68
|
+
try:
|
|
69
|
+
with open(path) as f: return json.load(f)
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _save(path, settings):
|
|
75
|
+
if os.path.exists(path):
|
|
76
|
+
backups = os.path.join(config.DATA, "backups"); os.makedirs(backups, exist_ok=True)
|
|
77
|
+
shutil.copy2(path, os.path.join(backups, "settings.json." + time.strftime("%Y%m%d-%H%M%S")))
|
|
78
|
+
_write_atomic(path, json.dumps(settings, indent=2) + "\n")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _drop_ours(settings):
|
|
82
|
+
"""Remove chatdeck's Notification hooks (any version), leaving everything else as it was."""
|
|
83
|
+
groups = (settings.get("hooks") or {}).get("Notification") or []
|
|
84
|
+
for g in groups:
|
|
85
|
+
g["hooks"] = [h for h in g.get("hooks", []) if not _ours(h.get("command", ""))]
|
|
86
|
+
groups[:] = [g for g in groups if g.get("hooks")]
|
|
87
|
+
if "hooks" in settings and not groups:
|
|
88
|
+
settings["hooks"].pop("Notification", None)
|
|
89
|
+
if not settings["hooks"]: settings.pop("hooks")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def install(path=config.CLAUDE_SETTINGS, cmd=None):
|
|
93
|
+
"""Add chatdeck's hooks. Safe to run again. Returns a list of what changed."""
|
|
94
|
+
cmd = cmd or command(); settings = _load(path); cfg = config.load(); done = []
|
|
95
|
+
ours = f"{cmd} hook statusline"
|
|
96
|
+
sl = settings.get("statusLine") or {}
|
|
97
|
+
current = sl.get("command", "")
|
|
98
|
+
if current != ours:
|
|
99
|
+
if current and not _ours(current):
|
|
100
|
+
cfg["statusline_command"] = current
|
|
101
|
+
done.append(f"status line: kept yours ({current}) and saves usage before running it")
|
|
102
|
+
else:
|
|
103
|
+
done.append("status line: saves usage for the dashboard")
|
|
104
|
+
settings["statusLine"] = {**sl, "type": "command", "command": ours}
|
|
105
|
+
_drop_ours(settings)
|
|
106
|
+
groups = settings.setdefault("hooks", {}).setdefault("Notification", [])
|
|
107
|
+
hook = {"type": "command", "command": f"{cmd} hook waiting", "async": True}
|
|
108
|
+
group = next((g for g in groups if g.get("matcher") == WAITING_MATCHER), None)
|
|
109
|
+
if group: group.setdefault("hooks", []).append(hook)
|
|
110
|
+
else: groups.append({"matcher": WAITING_MATCHER, "hooks": [hook]})
|
|
111
|
+
done.append("notification hook: flags chats waiting for you")
|
|
112
|
+
_save(path, settings); config.save(cfg)
|
|
113
|
+
return done
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def uninstall(path=config.CLAUDE_SETTINGS):
|
|
117
|
+
"""Take chatdeck's hooks out again and put back the status line that was there before."""
|
|
118
|
+
settings = _load(path); cfg = config.load(); done = []
|
|
119
|
+
sl = settings.get("statusLine") or {}
|
|
120
|
+
if _ours(sl.get("command", "")):
|
|
121
|
+
prev = cfg.pop("statusline_command", None)
|
|
122
|
+
if prev:
|
|
123
|
+
settings["statusLine"] = {**sl, "command": prev}; done.append(f"status line: back to {prev}")
|
|
124
|
+
else:
|
|
125
|
+
settings.pop("statusLine"); done.append("status line: removed")
|
|
126
|
+
before = json.dumps(settings.get("hooks"))
|
|
127
|
+
_drop_ours(settings)
|
|
128
|
+
if json.dumps(settings.get("hooks")) != before: done.append("notification hook: removed")
|
|
129
|
+
if done:
|
|
130
|
+
_save(path, settings); config.save(cfg)
|
|
131
|
+
return done
|