ctally 0.2.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.
ctally/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CTally: a floating status light for your Claude Code sessions. Whose turn is it?"""
2
+
3
+ __version__ = "0.2.0"
ctally/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
ctally/claude_hooks.py ADDED
@@ -0,0 +1,143 @@
1
+ """Add CTally's hooks to Claude Code's settings, or take them out again.
2
+
3
+ The settings file (~/.claude/settings.json) is merged, never overwritten: other hooks and
4
+ settings are left as they are, and a timestamped backup is written before any change.
5
+ Earlier CTally hooks, and those from when it was called Claude Pet, are replaced rather
6
+ than duplicated, so running it twice is harmless.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import tempfile
14
+ import time
15
+ from importlib import resources
16
+ from pathlib import Path
17
+
18
+ SETTINGS = Path.home() / ".claude" / "settings.json"
19
+ SCRIPT = Path.home() / ".claude" / "hooks" / "ctally.sh"
20
+ HOOK = '"$HOME/.claude/hooks/ctally.sh"'
21
+
22
+ # Each event, and the state it reports. No "matcher": these events take none.
23
+ EVENTS = {
24
+ "UserPromptSubmit": "working", # a turn starts
25
+ "PreToolUse": "working", # a tool is about to run
26
+ "PostToolUse": "working", # back to work after a permission prompt
27
+ "Stop": "done", # the turn is over, unless agents still run behind it
28
+ "Notification": "waiting", # a permission prompt, or idle waiting for input
29
+ "SessionEnd": "end", # the session is gone
30
+ "SubagentStart": "agent-start", # a subagent, maybe one of a workflow's, sets off
31
+ "SubagentStop": "agent-stop", # and comes back
32
+ }
33
+
34
+ # "claude-pet" catches the hooks from before the rename, inline or scripted.
35
+ MARKERS = ("ctally", "claude-pet")
36
+
37
+
38
+ class SettingsError(Exception):
39
+ """The settings file can't be read or isn't what we expect; it was left alone."""
40
+
41
+
42
+ def is_ours(hook: dict) -> bool:
43
+ return any(marker in hook.get("command", "") for marker in MARKERS)
44
+
45
+
46
+ def configure(settings: dict, install: bool) -> dict:
47
+ """Settings with every CTally hook removed, then (if installing) added back."""
48
+ settings = json.loads(json.dumps(settings)) # work on a copy
49
+ hooks = settings.get("hooks", {})
50
+ for event in list(hooks):
51
+ kept = []
52
+ for group in hooks[event]:
53
+ # Only our hooks go: others sharing a group with them (merged in by hand) stay.
54
+ theirs = [hook for hook in group.get("hooks", []) if not is_ours(hook)]
55
+ if len(theirs) == len(group.get("hooks", [])):
56
+ kept.append(group)
57
+ elif theirs:
58
+ kept.append({**group, "hooks": theirs})
59
+ if kept:
60
+ hooks[event] = kept
61
+ else:
62
+ del hooks[event]
63
+ if install:
64
+ for event, state in EVENTS.items():
65
+ hooks.setdefault(event, []).append(
66
+ {"hooks": [{"type": "command", "command": f"{HOOK} {state}"}]})
67
+ if hooks:
68
+ settings["hooks"] = hooks
69
+ else:
70
+ settings.pop("hooks", None)
71
+ return settings
72
+
73
+
74
+ def update(install: bool, path: Path = SETTINGS) -> str:
75
+ """Installs or removes the hooks in a settings file; returns what happened."""
76
+ settings = {}
77
+ # Only a file that is really not there counts as empty. One that merely can't be seen (no
78
+ # permission, or a sandbox hiding it) would otherwise be replaced by just our hooks.
79
+ try:
80
+ text = path.read_text(encoding="utf-8")
81
+ exists = True
82
+ except FileNotFoundError:
83
+ exists = False
84
+ except OSError as error:
85
+ raise SettingsError(f"Can't read {path} ({error.strerror}); left it alone.") from error
86
+ if exists:
87
+ try:
88
+ settings = json.loads(text) if text.strip() else {}
89
+ except json.JSONDecodeError as error:
90
+ raise SettingsError(f"{path} is not valid JSON ({error}); left it alone.") from error
91
+ if not isinstance(settings, dict):
92
+ raise SettingsError(f"{path} does not hold a JSON object; left it alone.")
93
+
94
+ updated = configure(settings, install)
95
+ if updated == settings and exists:
96
+ return f"Claude Code hooks already {'installed' if install else 'removed'} in {path}"
97
+
98
+ notes = []
99
+ if exists:
100
+ notes.append(f"Backed up {path} to {backup(path)}")
101
+ # A symlink (into a dotfiles checkout, say) is written through, not replaced by a file.
102
+ target = Path(os.path.realpath(path))
103
+ target.parent.mkdir(parents=True, exist_ok=True)
104
+ # Write beside the file, then swap it in, so a crash can't leave half a settings file.
105
+ fd, temp = tempfile.mkstemp(dir=target.parent, prefix=".settings.", suffix=".json")
106
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
107
+ json.dump(updated, f, indent=2, ensure_ascii=False)
108
+ f.write("\n")
109
+ if target.exists():
110
+ shutil.copymode(target, temp)
111
+ os.replace(temp, target)
112
+ notes.append(f"Claude Code hooks {'installed in' if install else 'removed from'} {path}")
113
+ return "\n".join(notes)
114
+
115
+
116
+ def backup(path: Path) -> Path:
117
+ """Copies a file aside, never over an earlier backup, even one made the same second."""
118
+ stamp = time.strftime("%Y%m%d-%H%M%S")
119
+ copy, n = Path(f"{path}.bak.{stamp}"), 1
120
+ while copy.exists():
121
+ n += 1
122
+ copy = Path(f"{path}.bak.{stamp}-{n}")
123
+ shutil.copy2(path, copy)
124
+ return copy
125
+
126
+
127
+ def install_script() -> None:
128
+ """Puts the hook script where the hooks call it."""
129
+ SCRIPT.parent.mkdir(parents=True, exist_ok=True)
130
+ source = resources.files("ctally").joinpath("hooks/ctally.sh").read_bytes()
131
+ temp = SCRIPT.with_name(".ctally.sh.new")
132
+ temp.write_bytes(source)
133
+ temp.chmod(0o755)
134
+ os.replace(temp, SCRIPT)
135
+
136
+
137
+ def remove_scripts() -> None:
138
+ for name in ("ctally.sh", "claude-pet.sh"):
139
+ (SCRIPT.parent / name).unlink(missing_ok=True)
140
+ try:
141
+ SCRIPT.parent.rmdir() # only if nothing else lives there
142
+ except OSError:
143
+ pass
ctally/cli.py ADDED
@@ -0,0 +1,180 @@
1
+ """ctally: CTally from the shell and from tmux.
2
+
3
+ ctally run start the indicator (setup arranges this at login)
4
+ ctally setup [--tmux] install the Claude Code hooks, start CTally at login, and start it
5
+ now; --tmux also binds prefix + J and puts the counts in tmux's
6
+ status line
7
+ ctally uninstall undo all of that (then: pipx uninstall ctally)
8
+
9
+ ctally status [--tmux] counts of live sessions by state, most urgent first: "!1 ▶2 ✓5 ·1"
10
+ (waiting, working, done, idle); --tmux adds status-line colours
11
+ ctally list one line per live session: state, tmux pane, subagents running,
12
+ project, id
13
+ ctally jump [pane client socket]
14
+ switch a tmux client to the session that needs you: waiting first,
15
+ then finished, longest-waiting first. Run it again to move on to
16
+ the next one. A key binding passes '#{pane_id}' '#{client_name}'
17
+ '#{socket_path}'.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import os
23
+ import sys
24
+ from typing import NamedTuple
25
+
26
+ from . import __version__, system
27
+ from .sessions import STATE_DIR
28
+
29
+ GLYPH = {"waiting": "!", "working": "▶", "done": "✓", "idle": "·"}
30
+ COLOUR = {"waiting": "#ffb329", "working": "#4accf2", "done": "#45e087", "idle": "#99a6bd"}
31
+ RANK = {"waiting": 0, "done": 1, "working": 2, "idle": 3}
32
+
33
+
34
+ class Live(NamedTuple):
35
+ rank: int
36
+ modified: float
37
+ state: str
38
+ pid: int
39
+ id: str
40
+ project: str
41
+
42
+
43
+ def live_sessions() -> list[Live]:
44
+ """Each live session, most urgent first and, within a state, longest-waiting first.
45
+ Sessions whose claude process is gone are skipped."""
46
+ found = []
47
+ try:
48
+ names = [n for n in os.listdir(STATE_DIR) if not n.startswith(".")]
49
+ except OSError:
50
+ names = []
51
+ for name in names:
52
+ path = STATE_DIR / name
53
+ try:
54
+ fields = path.read_text(errors="replace").split()
55
+ modified = path.stat().st_mtime
56
+ except OSError:
57
+ continue
58
+ if len(fields) < 2 or not fields[1].isdigit():
59
+ continue
60
+ pid = int(fields[1])
61
+ if not system.is_alive(pid):
62
+ continue
63
+ state = fields[0] if fields[0] in RANK else "idle"
64
+ found.append(Live(RANK[state], modified, state, pid, name, " ".join(fields[2:]) or "?"))
65
+ return sorted(found)
66
+
67
+
68
+ def agents_running(sid: str) -> int:
69
+ try:
70
+ return sum(1 for n in os.listdir(STATE_DIR / ".agents" / sid) if not n.startswith("."))
71
+ except OSError:
72
+ return 0
73
+
74
+
75
+ def status(tmux: bool) -> None:
76
+ counts: dict[str, int] = {}
77
+ for session in live_sessions():
78
+ counts[session.state] = counts.get(session.state, 0) + 1
79
+ items = []
80
+ for state in ("waiting", "working", "done", "idle"):
81
+ if counts.get(state):
82
+ item = f"{GLYPH[state]}{counts[state]}"
83
+ items.append(f"#[fg={COLOUR[state]}]{item}" if tmux else item)
84
+ out = " ".join(items)
85
+ print(out + "#[default]" if tmux and out else out)
86
+
87
+
88
+ def list_sessions() -> None:
89
+ for session in live_sessions():
90
+ target = system.tmux_pane(session.pid)
91
+ location = (system.tmux_location(target) if target else None) or "-"
92
+ n = agents_running(session.id)
93
+ agents = "-" if n == 0 else "1 agent" if n == 1 else f"{n} agents"
94
+ print(f"{session.state:<8} {location:<16} {agents:<9} {session.project:<24} {session.id}")
95
+
96
+
97
+ def jump(current: str | None, client: str | None, here: str | None) -> int:
98
+ current = current or os.environ.get("TMUX_PANE", "")
99
+ here = here or os.environ.get("TMUX", "").split(",", 1)[0]
100
+ binary = system.tmux_binary() or "tmux"
101
+
102
+ def say(message: str) -> None:
103
+ if client and here:
104
+ system.run([binary, "-S", here, "display-message", "-c", client, message])
105
+ else:
106
+ print(message)
107
+
108
+ # Sessions that need me, in tmux, in order.
109
+ queue = []
110
+ for session in live_sessions():
111
+ if session.rank > 1:
112
+ continue
113
+ target = system.tmux_pane(session.pid)
114
+ if target:
115
+ queue.append((target, session))
116
+ if not queue:
117
+ say("CTally: nothing needs you")
118
+ return 0
119
+
120
+ # The one after the pane I'm on, so pressing again moves along the queue.
121
+ at = next((i for i, (target, _) in enumerate(queue) if target.pane == current), None)
122
+ target, session = queue[at + 1] if at is not None and at + 1 < len(queue) else queue[0]
123
+
124
+ if not here:
125
+ print(f"ctally jump: run it inside tmux (or bind it to a key); next up is "
126
+ f"{session.project} ({session.state})", file=sys.stderr)
127
+ return 1
128
+ if target.socket != here:
129
+ say(f"CTally: {session.project} is on another tmux server")
130
+ return 1
131
+ switch = [binary, "-S", target.socket, "switch-client"] + (["-c", client] if client else []) + ["-t", target.pane]
132
+ if system.run(switch) is None:
133
+ say(f"CTally: couldn't switch to {session.project}")
134
+ return 1
135
+ say(f"CTally: {session.project} · {session.state}")
136
+ return 0
137
+
138
+
139
+ def main(argv: list[str] | None = None) -> int:
140
+ parser = argparse.ArgumentParser(prog="ctally", description=__doc__.split("\n")[0],
141
+ formatter_class=argparse.RawDescriptionHelpFormatter,
142
+ epilog="\n".join(__doc__.split("\n")[2:]))
143
+ parser.add_argument("--version", action="version", version=f"ctally {__version__}")
144
+ commands = parser.add_subparsers(dest="command", metavar="command")
145
+ commands.add_parser("run", help="start the indicator")
146
+ setup = commands.add_parser("setup", help="install hooks, start at login, start now")
147
+ setup.add_argument("--tmux", action="store_true", help="also bind prefix + J and add tmux status-line counts")
148
+ setup.add_argument("--no-hooks", action="store_true", help="leave ~/.claude/settings.json alone")
149
+ setup.add_argument("--no-autostart", action="store_true", help="don't start CTally at login")
150
+ setup.add_argument("--no-launch", action="store_true", help="don't start CTally now")
151
+ commands.add_parser("uninstall", help="remove hooks, tmux setup, autostart and state")
152
+ status_ = commands.add_parser("status", help="counts by state")
153
+ status_.add_argument("--tmux", action="store_true", help="with tmux status-line colours")
154
+ commands.add_parser("list", help="one line per live session")
155
+ jump_ = commands.add_parser("jump", help="switch tmux to the session that needs you")
156
+ jump_.add_argument("pane", nargs="?")
157
+ jump_.add_argument("client", nargs="?")
158
+ jump_.add_argument("socket", nargs="?")
159
+ args = parser.parse_args(argv)
160
+
161
+ if args.command == "run":
162
+ from .gui.app import run # Qt loads only for the indicator
163
+ return run()
164
+ if args.command == "setup":
165
+ from . import install
166
+ return install.setup(tmux=args.tmux, hooks=not args.no_hooks,
167
+ autostart=not args.no_autostart, launch=not args.no_launch)
168
+ if args.command == "uninstall":
169
+ from . import install
170
+ return install.uninstall()
171
+ if args.command == "status":
172
+ status(args.tmux)
173
+ return 0
174
+ if args.command == "list":
175
+ list_sessions()
176
+ return 0
177
+ if args.command == "jump":
178
+ return jump(args.pane, args.client, args.socket)
179
+ parser.print_help()
180
+ return 2
ctally/focus.py ADDED
@@ -0,0 +1,144 @@
1
+ """Brings a session's terminal to the front.
2
+
3
+ Inside tmux, that means switching tmux to the session's pane, then raising the terminal
4
+ attached to that tmux session (or opening one if none is). Outside tmux, it means raising the
5
+ terminal the session runs in: on macOS the exact Terminal tab, or the app for anything else
6
+ (iTerm2, VS Code, the Claude desktop app); on Linux the terminal's window, where the desktop
7
+ allows it (X11, with xdotool installed; Wayland lets no app raise another's window).
8
+
9
+ All of this shells out, so it runs off the main thread, and it stays silent when anything
10
+ along the way is missing.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import shlex
15
+ import shutil
16
+ import subprocess
17
+ import threading
18
+
19
+ from . import system
20
+
21
+ TERMINAL = "com.apple.Terminal"
22
+
23
+ # Picks the Terminal tab whose tty matches, and puts its window in front.
24
+ RAISE_TAB = """
25
+ on run argv
26
+ set target to item 1 of argv
27
+ tell application "Terminal"
28
+ repeat with w in windows
29
+ repeat with t in tabs of w
30
+ if tty of t is target then
31
+ set miniaturized of w to false
32
+ set selected tab of w to t
33
+ set index of w to 1
34
+ activate
35
+ return
36
+ end if
37
+ end repeat
38
+ end repeat
39
+ end tell
40
+ end run
41
+ """
42
+
43
+
44
+ def focus(pid: int) -> None:
45
+ if pid > 0:
46
+ threading.Thread(target=_focus_now, args=(pid,), daemon=True).start()
47
+
48
+
49
+ def _focus_now(pid: int) -> None:
50
+ target = system.tmux_pane(pid)
51
+ if target:
52
+ _focus_tmux(target)
53
+ else:
54
+ _raise(system.tty(pid), pid)
55
+
56
+
57
+ def _focus_tmux(target: system.TmuxPane) -> None:
58
+ def tmux(*args: str) -> str | None:
59
+ return system.run([target.binary, "-S", target.socket, *args])
60
+
61
+ tmux("select-window", "-t", target.pane)
62
+ tmux("select-pane", "-t", target.pane)
63
+ session = (tmux("display-message", "-p", "-t", target.pane, "#{session_name}") or "").strip()
64
+ if not session:
65
+ return
66
+
67
+ # The terminal attached to that tmux session that I used most recently.
68
+ clients = []
69
+ for line in (tmux("list-clients", "-t", "=" + session,
70
+ "-F", "#{client_activity} #{client_pid} #{client_tty}") or "").splitlines():
71
+ fields = line.split(" ", 2)
72
+ if len(fields) == 3 and fields[0].isdigit() and fields[1].isdigit():
73
+ clients.append((int(fields[0]), int(fields[1]), fields[2]))
74
+ if clients:
75
+ _, client_pid, client_tty = max(clients)
76
+ _raise(client_tty, client_pid)
77
+ return
78
+
79
+ # Nobody is looking at that session: open a terminal onto it.
80
+ attach = shlex.join([target.binary, "-S", target.socket, "attach-session", "-t", "=" + session])
81
+ if system.MAC:
82
+ quoted = attach.replace("\\", "\\\\").replace('"', '\\"')
83
+ system.run(["osascript", "-e", 'tell application "Terminal"', "-e", f'do script "{quoted}"',
84
+ "-e", "activate", "-e", "end tell"])
85
+ elif shutil.which("x-terminal-emulator"):
86
+ subprocess.Popen(["x-terminal-emulator", "-e", "sh", "-c", attach], start_new_session=True,
87
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
88
+
89
+
90
+ def _raise(tty: str | None, pid: int) -> None:
91
+ if system.MAC:
92
+ _raise_mac(tty, pid)
93
+ elif system.LINUX:
94
+ _raise_x11(pid)
95
+
96
+
97
+ def _raise_mac(tty: str | None, pid: int) -> None:
98
+ app = _host_app(pid)
99
+ if app is None:
100
+ return
101
+ if tty and app.bundleIdentifier() == TERMINAL:
102
+ system.run(["osascript", "-e", RAISE_TAB, tty])
103
+ return
104
+ # The way the Dock does it: since macOS 14 an app can't push itself past the active one,
105
+ # and CTally is never active, so asking the app directly may be ignored.
106
+ from AppKit import NSWorkspace, NSWorkspaceOpenConfiguration
107
+ url = app.bundleURL()
108
+ if url is None:
109
+ app.activateWithOptions_(0)
110
+ return
111
+ configuration = NSWorkspaceOpenConfiguration.configuration()
112
+ configuration.setActivates_(True)
113
+ NSWorkspace.sharedWorkspace().openApplicationAtURL_configuration_completionHandler_(url, configuration, None)
114
+
115
+
116
+ def _host_app(pid: int):
117
+ """The nearest ancestor that is an ordinary app: Terminal, for a shell in one of its tabs."""
118
+ from AppKit import NSApplicationActivationPolicyRegular, NSRunningApplication
119
+ current = pid
120
+ for _ in range(32):
121
+ app = NSRunningApplication.runningApplicationWithProcessIdentifier_(current)
122
+ if app is not None and app.activationPolicy() == NSApplicationActivationPolicyRegular:
123
+ return app
124
+ up = system.parent(current)
125
+ if not up or up <= 1 or up == current:
126
+ return None
127
+ current = up
128
+ return None
129
+
130
+
131
+ def _raise_x11(pid: int) -> None:
132
+ """Activates the window of the nearest ancestor that has one: the terminal emulator."""
133
+ if not shutil.which("xdotool"):
134
+ return
135
+ current = pid
136
+ for _ in range(32):
137
+ windows = (system.run(["xdotool", "search", "--pid", str(current)]) or "").split()
138
+ if windows:
139
+ system.run(["xdotool", "windowactivate", windows[-1]])
140
+ return
141
+ up = system.parent(current)
142
+ if not up or up <= 1 or up == current:
143
+ return
144
+ current = up
ctally/gui/__init__.py ADDED
File without changes