worklog-for-claude-code 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.
worklog/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """claude-worklog: daily work log + token report for Claude Code."""
2
+
3
+ __version__ = "0.1.0"
worklog/cli.py ADDED
@@ -0,0 +1,249 @@
1
+ """Command line interface: init, doctor, install-hook, hook, collect, report, serve, demo."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import logging
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from datetime import date, datetime, timedelta
11
+ from logging.handlers import RotatingFileHandler
12
+
13
+ from . import __version__
14
+ from .config import CONFIG_PATH, STATE_DIR, ConfigError, example_config, load_config
15
+ from .hooks import HOOK_EVENTS, install_hook, installed_events
16
+
17
+ log = logging.getLogger("worklog")
18
+
19
+
20
+ def _setup_logging(verbose: bool, quiet: bool = False) -> None:
21
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
22
+ root = logging.getLogger()
23
+ root.setLevel(logging.DEBUG if verbose else logging.INFO)
24
+ fh = RotatingFileHandler(STATE_DIR / "worklog.log", maxBytes=1_000_000, backupCount=3)
25
+ fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
26
+ root.addHandler(fh)
27
+ if not quiet:
28
+ sh = logging.StreamHandler(sys.stderr)
29
+ sh.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
30
+ sh.setLevel(logging.DEBUG if verbose else logging.WARNING)
31
+ root.addHandler(sh)
32
+
33
+
34
+ def _parse_day(value: str) -> date:
35
+ v = value.lower()
36
+ if v == "today":
37
+ return date.today()
38
+ if v == "yesterday":
39
+ return date.today() - timedelta(days=1)
40
+ try:
41
+ return date.fromisoformat(value)
42
+ except ValueError as exc:
43
+ raise argparse.ArgumentTypeError("use YYYY-MM-DD, 'today' or 'yesterday'") from exc
44
+
45
+
46
+ # ------------------------------------------------------------------ commands
47
+ def cmd_init(args) -> int:
48
+ if CONFIG_PATH.exists() and not args.force:
49
+ print(f"Config already exists: {CONFIG_PATH} (use --force to overwrite)")
50
+ return 1
51
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
52
+ CONFIG_PATH.write_text(example_config(), encoding="utf-8")
53
+ CONFIG_PATH.chmod(0o600)
54
+ print(f"Wrote {CONFIG_PATH}. Edit it, then run `claude-worklog doctor`.")
55
+ return 0
56
+
57
+
58
+ def cmd_doctor(args) -> int:
59
+ ok = True
60
+ try:
61
+ cfg = load_config()
62
+ except ConfigError as exc:
63
+ print(f"✗ config: {exc}")
64
+ return 1
65
+ print(f"✓ config: {cfg.path} (device={cfg.device})")
66
+ git_dir = cfg.store_repo / ".git"
67
+ if git_dir.exists():
68
+ remote = subprocess.run(["git", "-C", str(cfg.store_repo), "remote"],
69
+ capture_output=True, text=True).stdout.strip()
70
+ print(f"✓ store repo: {cfg.store_repo} (remote: {remote or 'none — local only'})")
71
+ else:
72
+ ok = False
73
+ print(f"✗ store repo: {cfg.store_repo} is not a git clone")
74
+ from .identity import detected_logins, mask_email
75
+
76
+ for a in cfg.accounts:
77
+ proj = a.config_dir / "projects"
78
+ n = sum(1 for _ in proj.rglob("*.jsonl")) if proj.is_dir() else 0
79
+ mark = "✓" if proj.is_dir() else "✗"
80
+ ok &= proj.is_dir()
81
+ events = installed_events(a)
82
+ missing = [ev for ev in HOOK_EVENTS if ev not in events]
83
+ hooks = "hooks installed" if not missing else f"hook NOT installed: {', '.join(missing)}"
84
+ role = (" [matched login]" if a.matched else
85
+ " [default for shared folder]" if len(cfg.accounts_by_dir()[a.config_dir]) > 1 else "")
86
+ print(f"{mark} account {a.name}{role}: {a.config_dir} ({n} transcripts, {hooks})")
87
+ for login in detected_logins(): # e-mails masked: doctor output gets pasted into issues
88
+ for d in login["dirs"]:
89
+ if d in cfg.accounts_by_dir():
90
+ name = cfg.resolve_account(d, login["account_id"], login["email"]).name
91
+ print(f" detected login {mask_email(login['email'])} in {d} -> counted as {name}")
92
+ exe = shutil.which(cfg.claude_bin)
93
+ missing = cfg.claude_bin + " not found (AI summaries will fall back to plain)"
94
+ print(f"{'✓' if exe else '!'} claude binary: {exe or missing}")
95
+ return 0 if ok else 1
96
+
97
+
98
+ def cmd_install_hook(args) -> int:
99
+ """Add the SessionStart and SessionEnd hooks to each account's settings.json (backup first, idempotent)."""
100
+ cfg = load_config()
101
+ targets = [a for a in cfg.accounts if not args.account or a.name == args.account]
102
+ if not targets:
103
+ print(f"No account named {args.account!r}")
104
+ return 1
105
+ ok_all = True
106
+ for a in targets:
107
+ ok, msg = install_hook(a)
108
+ ok_all &= ok
109
+ print(("✓ " if ok else "✗ ") + msg)
110
+ return 0 if ok_all else 1
111
+
112
+
113
+ def cmd_serve(args) -> int:
114
+ from .web import serve
115
+ serve(args.port)
116
+ return 0
117
+
118
+
119
+ def cmd_demo(args) -> int:
120
+ from .demo import run
121
+ return run(args.port, seed=args.seed)
122
+
123
+
124
+ def cmd_install_service(args) -> int:
125
+ from . import services
126
+ from .web import HOST_NAME
127
+ try:
128
+ for msg in services.install(args.port, report=args.report, start=not args.no_start):
129
+ print(msg)
130
+ except RuntimeError as exc:
131
+ print(f"✗ {exc}")
132
+ return 1
133
+ if not args.no_start:
134
+ print(f"\n✓ Worklog UI is running: http://{HOST_NAME}:{args.port}")
135
+ print(" It starts automatically whenever you log in. Remove any cron lines you added earlier.")
136
+ return 0
137
+
138
+
139
+ def cmd_hook(args) -> int:
140
+ """Called by Claude Code at SessionStart and SessionEnd.
141
+
142
+ Must be fast, must never fail the session and must print nothing: SessionStart
143
+ stdout is added to Claude's context. SessionEnd hooks share a 1.5 s budget.
144
+ """
145
+ from .identity import note_session
146
+
147
+ try:
148
+ payload = sys.stdin.read(1_000_000) if not sys.stdin.isatty() else ""
149
+ data = json.loads(payload) if payload.strip() else {}
150
+ data = data if isinstance(data, dict) else {}
151
+ sid = data.get("session_id")
152
+ event = data.get("hook_event_name") or "SessionEnd" # hooks installed by 0.1.0 were SessionEnd only
153
+ if event == "SessionStart":
154
+ note_session(sid, "start")
155
+ return 0
156
+ log.info("SessionEnd hook for session %s; spawning background collect", str(sid)[:8])
157
+ subprocess.Popen( # detached so Claude Code exits immediately
158
+ [sys.executable, "-m", "worklog.cli", "--quiet", "collect", "--days", "2"],
159
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
160
+ start_new_session=True,
161
+ )
162
+ note_session(sid, "end") # after the spawn: the collect must not wait on us
163
+ except Exception: # noqa: BLE001 - a tracking hook must never break Claude Code
164
+ log.exception("hook failed")
165
+ return 0
166
+
167
+
168
+ def cmd_collect(args) -> int:
169
+ from .collector import collect
170
+
171
+ cfg = load_config()
172
+ end = args.date or date.today()
173
+ days = [end - timedelta(days=i) for i in reversed(range(args.days))]
174
+ n = collect(cfg, days)
175
+ print(f"collected {n} new/updated record(s) for {days[0]}..{days[-1]}")
176
+ return 0
177
+
178
+
179
+ def cmd_report(args) -> int:
180
+ from .report import build_report
181
+
182
+ cfg = load_config()
183
+ now = datetime.now()
184
+ day = args.date or (date.today() - timedelta(days=1) if args.auto and now.hour < 6 else date.today())
185
+ use_ai = cfg.use_ai and not args.no_ai
186
+ if not args.no_collect:
187
+ from .collector import collect
188
+ collect(cfg, [day])
189
+ text, path = build_report(cfg, day, use_ai=use_ai, write=not args.stdout)
190
+ print(text if args.stdout else f"report written: {path}")
191
+ return 0
192
+
193
+
194
+ def main(argv: list[str] | None = None) -> int:
195
+ ap = argparse.ArgumentParser(prog="claude-worklog", description=__doc__)
196
+ ap.add_argument("--version", action="version", version=__version__)
197
+ ap.add_argument("-v", "--verbose", action="store_true")
198
+ ap.add_argument("--quiet", action="store_true", help=argparse.SUPPRESS)
199
+ sub = ap.add_subparsers(dest="cmd", required=True)
200
+
201
+ p = sub.add_parser("init", help="write an example config")
202
+ p.add_argument("--force", action="store_true")
203
+ p.set_defaults(func=cmd_init)
204
+ sub.add_parser("doctor", help="check config, store repo, accounts, hooks").set_defaults(func=cmd_doctor)
205
+ p = sub.add_parser("install-hook", help="add SessionStart/SessionEnd hooks to each account's settings.json")
206
+ p.add_argument("--account")
207
+ p.set_defaults(func=cmd_install_hook)
208
+ sub.add_parser("hook", help="(called by Claude Code)").set_defaults(func=cmd_hook)
209
+ p = sub.add_parser("collect", help="parse transcripts and sync records")
210
+ p.add_argument("--date", type=_parse_day, help="last day to collect (default today)")
211
+ p.add_argument("--days", type=int, default=1, choices=range(1, 31), metavar="N", help="days back (1-30)")
212
+ p.set_defaults(func=cmd_collect)
213
+ p = sub.add_parser("report", help="build the daily report")
214
+ p.add_argument("--date", type=_parse_day, help="YYYY-MM-DD, today (default) or yesterday")
215
+ p.add_argument("--no-ai", action="store_true", help="skip claude -p summaries")
216
+ p.add_argument("--no-collect", action="store_true", help="don't collect this device first")
217
+ p.add_argument("--stdout", action="store_true", help="print instead of writing to the store")
218
+ p.add_argument("--auto", action="store_true", help="before 06:00 report yesterday, else today (for the timer)")
219
+ p.set_defaults(func=cmd_report)
220
+ p = sub.add_parser("serve", help="run the web UI on loopback")
221
+ p.add_argument("--port", type=int, default=8765)
222
+ p.set_defaults(func=cmd_serve)
223
+ p = sub.add_parser("demo", help="try the web UI on generated fake data (touches none of your files)")
224
+ p.add_argument("--port", type=int, default=8766)
225
+ p.add_argument("--seed", type=int, default=7, help="change for different fake data")
226
+ p.set_defaults(func=cmd_demo)
227
+ p = sub.add_parser("install-service", help="run the UI + collect/report timers as systemd user services")
228
+ p.add_argument("--port", type=int, default=8765)
229
+ p.add_argument("--report", action=argparse.BooleanOptionalAction, default=True,
230
+ help="build the daily report on this device (use --no-report on all but one device)")
231
+ p.add_argument("--no-start", action="store_true", help="only write unit files")
232
+ p.set_defaults(func=cmd_install_service)
233
+
234
+ args = ap.parse_args(argv)
235
+ if args.cmd == "demo": # its own process must not log into the real state folder
236
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
237
+ return args.func(args)
238
+ _setup_logging(args.verbose, quiet=args.quiet or args.cmd == "hook")
239
+ if args.cmd == "serve":
240
+ logging.getLogger().handlers[-1].setLevel(logging.INFO) # journald shows startup + errors
241
+ try:
242
+ return args.func(args)
243
+ except (ConfigError, TimeoutError) as exc:
244
+ log.error("%s", exc)
245
+ return 2
246
+
247
+
248
+ if __name__ == "__main__":
249
+ sys.exit(main())
worklog/collector.py ADDED
@@ -0,0 +1,197 @@
1
+ """Collector: local Claude Code transcripts -> redacted per-session records in the store."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import re
6
+ import subprocess
7
+ from datetime import date, datetime, time, timedelta
8
+ from pathlib import Path
9
+ from urllib.parse import urlsplit
10
+
11
+ from .config import SUMMARIZER_CWD, Account, Config
12
+ from .identity import Identity, session_logins
13
+ from .parser import ParseStats, SessionDay, parse_transcript
14
+ from .redact import redact
15
+ from .store import GitStore
16
+
17
+ log = logging.getLogger(__name__)
18
+ SCHEMA = 1
19
+ _SAFE = re.compile(r"[^A-Za-z0-9_-]")
20
+
21
+
22
+ def _home_relative(p: Path | str) -> str:
23
+ p = str(p)
24
+ home = str(Path.home())
25
+ return "~" + p[len(home):] if p == home or p.startswith(home + "/") else p
26
+
27
+
28
+ def find_git_root(start: Path) -> Path | None:
29
+ for p in (start, *start.parents):
30
+ if (p / ".git").exists():
31
+ return p
32
+ return None
33
+
34
+
35
+ def sanitize_remote(url: str) -> dict | None:
36
+ """Reduce a remote URL to host + repo slug. Credentials are always dropped.
37
+
38
+ Handles https://user:token@host/o/r.git, ssh://git@host:22/o/r.git, git@host:o/r.git
39
+ """
40
+ url = url.strip()
41
+ if not url:
42
+ return None
43
+ m = re.match(r"^(?:[^@/\s]+@)?([^:/\s]+):(?!//)(.+)$", url) # scp-like syntax
44
+ if m and "://" not in url:
45
+ host, path = m.group(1), m.group(2)
46
+ else:
47
+ parts = urlsplit(url)
48
+ if not parts.hostname:
49
+ return None
50
+ host, path = parts.hostname, parts.path
51
+ slug = path.strip("/").removesuffix(".git")
52
+ return {"host": host.lower(), "slug": slug} if slug else None
53
+
54
+
55
+ def _git(root: Path, *args: str) -> str | None:
56
+ try:
57
+ out = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=15)
58
+ except (OSError, subprocess.TimeoutExpired) as exc:
59
+ log.debug("git %s failed in %s: %s", args[0], root, exc)
60
+ return None
61
+ return out.stdout if out.returncode == 0 else None
62
+
63
+
64
+ def _commits(root: Path, day: date) -> list[dict]:
65
+ """Your own non-merge commits on `day` (local time), across all branches."""
66
+ email = (_git(root, "config", "user.email") or "").strip()
67
+ if not email:
68
+ return [] # no identity -> can't tell which commits are yours; include none
69
+ since = datetime.combine(day, time.min).astimezone().isoformat()
70
+ until = datetime.combine(day + timedelta(days=1), time.min).astimezone().isoformat()
71
+ out = _git(root, "log", "--all", "--no-merges", f"--author={email}", f"--since={since}",
72
+ f"--until={until}", "--format=%h%x09%s", "--max-count=100")
73
+ commits = []
74
+ for line in (out or "").splitlines():
75
+ h, _, subject = line.partition("\t")
76
+ if h:
77
+ commits.append({"hash": h, "subject": redact(subject)[:200]})
78
+ return commits
79
+
80
+
81
+ def build_record(sd: SessionDay, account: Account, cfg: Config) -> dict | None:
82
+ cwd = sd.cwd_counts.most_common(1)[0][0] if sd.cwd_counts else None
83
+ cwd_path = Path(cwd) if cwd else None
84
+ if cwd_path and (cwd_path == SUMMARIZER_CWD or SUMMARIZER_CWD in cwd_path.parents):
85
+ return None # our own `claude -p` summarization runs
86
+ if sd.api_calls == 0 and not sd.prompts:
87
+ return None
88
+
89
+ root = find_git_root(cwd_path) if cwd_path and cwd_path.exists() else None
90
+ project: dict = {"branches": sorted(sd.branches)}
91
+ if root:
92
+ remote = sanitize_remote(_git(root, "remote", "get-url", "origin") or "")
93
+ project.update(kind="git", name=root.name, path=_home_relative(root), remote=remote)
94
+ else:
95
+ project.update(kind="misc", name=cfg.misc_label, path=_home_relative(cwd) if cwd else None,
96
+ folder=cwd_path.name if cwd_path else None, remote=None)
97
+
98
+ files = []
99
+ for f in sorted(sd.files)[:200]:
100
+ fp = Path(f)
101
+ files.append(str(fp.relative_to(root)) if root and root in fp.parents else _home_relative(fp))
102
+
103
+ prompts = [redact(p)[: cfg.max_prompt_chars] for p in sd.prompts[: cfg.max_prompts]]
104
+ minutes = round((sd.last - sd.first).total_seconds() / 60) if sd.first and sd.last else 0
105
+ return {
106
+ "schema": SCHEMA,
107
+ "session_id": sd.session_id,
108
+ "date": sd.day.isoformat(),
109
+ "account": account.name,
110
+ "device": cfg.device,
111
+ "project": project,
112
+ "start": sd.first.isoformat(timespec="seconds") if sd.first else None,
113
+ "end": sd.last.isoformat(timespec="seconds") if sd.last else None,
114
+ "span_minutes": minutes,
115
+ "api_calls": sd.api_calls,
116
+ "tokens": sd.tokens,
117
+ "tokens_by_model": sd.tokens_by_model,
118
+ "prompt_count": len(sd.prompts),
119
+ "prompts": prompts,
120
+ "files_touched": files,
121
+ "tool_calls": dict(sd.tool_calls),
122
+ "commits": _commits(root, sd.day) if root and cfg.include_commits else [],
123
+ }
124
+
125
+
126
+ def _transcripts(config_dir: Path, label: str, day: date) -> list[Path]:
127
+ base = config_dir / "projects"
128
+ if not base.is_dir():
129
+ log.warning("account %s: no transcripts dir at %s", label, base)
130
+ return []
131
+ cutoff = datetime.combine(day, time.min).astimezone().timestamp()
132
+ found = []
133
+ for p in base.rglob("*.jsonl"):
134
+ try:
135
+ mtime = p.stat().st_mtime
136
+ except OSError:
137
+ continue # deleted while scanning
138
+ if mtime >= cutoff: # a file untouched since before `day` can't hold entries for it
139
+ found.append((mtime, p))
140
+ return [p for _, p in sorted(found)]
141
+
142
+
143
+ def _move_stale(store: GitStore, day: date, device: str, account: str, fname: str) -> int:
144
+ """Move this session's record from another account's folder into `account`'s.
145
+
146
+ Happens when a login is named after its sessions were already collected under the
147
+ folder's default. Only called for sessions whose login the hook recorded, so records
148
+ written before accounts were tracked are never touched. Git history keeps the old path.
149
+ """
150
+ moved = 0
151
+ base = store.path / "records" / day.isoformat() / device
152
+ for old in sorted(base.glob(f"*/{fname}")) if base.is_dir() else []:
153
+ if old.parent.name == account:
154
+ continue
155
+ src, dst = str(old.relative_to(store.path)), f"records/{day.isoformat()}/{device}/{account}/{fname}"
156
+ store.move(src, dst)
157
+ log.info("session %s: record moved from account %s to %s", fname[:8], old.parent.name, account)
158
+ moved += 1
159
+ return moved
160
+
161
+
162
+ def collect(cfg: Config, days: list[date]) -> int:
163
+ store = GitStore(cfg.store_repo)
164
+ logins: dict[str, Identity] = session_logins() # local state written by the SessionStart hook
165
+ written = 0
166
+ with store.lock():
167
+ store.pull()
168
+ for day in days:
169
+ # Several accounts may share one folder: read it once, then pick the account per session.
170
+ for config_dir, group in cfg.accounts_by_dir().items():
171
+ label = "/".join(a.name for a in group)
172
+ stats = ParseStats()
173
+ sessions: dict[str, SessionDay] = {}
174
+ seen_usage: set[tuple] = set()
175
+ seen_uuids: set[str] = set()
176
+ for path in _transcripts(config_dir, label, day):
177
+ try:
178
+ parse_transcript(path, day, sessions, seen_usage, seen_uuids, stats)
179
+ except OSError as exc:
180
+ log.warning("cannot read %s: %s", path, exc)
181
+ for sd in sessions.values():
182
+ login = logins.get(sd.session_id)
183
+ account = (cfg.resolve_account(config_dir, login.account_id, login.email) if login
184
+ else cfg.default_account(config_dir)) # unrecorded / older sessions
185
+ record = build_record(sd, account, cfg)
186
+ if record is None:
187
+ continue
188
+ fname = f"{_SAFE.sub('_', sd.session_id)}.json"
189
+ if login:
190
+ written += _move_stale(store, day, cfg.device, account.name, fname)
191
+ rel = f"records/{day.isoformat()}/{cfg.device}/{account.name}/{fname}"
192
+ written += store.write_json(rel, record)
193
+ log.info("%s %s: %d files, %d lines (%d malformed), %d sessions",
194
+ day, label, stats.files, stats.lines, stats.bad_lines, len(sessions))
195
+ if written:
196
+ store.commit_and_push(f"collect {cfg.device}: {written} record(s)")
197
+ return written