worklog-for-claude-code 0.1.0__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.
Files changed (27) hide show
  1. worklog_for_claude_code-0.1.0/LICENSE +21 -0
  2. worklog_for_claude_code-0.1.0/PKG-INFO +112 -0
  3. worklog_for_claude_code-0.1.0/README.md +76 -0
  4. worklog_for_claude_code-0.1.0/pyproject.toml +63 -0
  5. worklog_for_claude_code-0.1.0/setup.cfg +4 -0
  6. worklog_for_claude_code-0.1.0/tests/test_accounts.py +299 -0
  7. worklog_for_claude_code-0.1.0/tests/test_demo.py +145 -0
  8. worklog_for_claude_code-0.1.0/tests/test_worklog.py +54 -0
  9. worklog_for_claude_code-0.1.0/worklog/__init__.py +3 -0
  10. worklog_for_claude_code-0.1.0/worklog/cli.py +249 -0
  11. worklog_for_claude_code-0.1.0/worklog/collector.py +197 -0
  12. worklog_for_claude_code-0.1.0/worklog/config.py +294 -0
  13. worklog_for_claude_code-0.1.0/worklog/demo.py +214 -0
  14. worklog_for_claude_code-0.1.0/worklog/hooks.py +78 -0
  15. worklog_for_claude_code-0.1.0/worklog/identity.py +238 -0
  16. worklog_for_claude_code-0.1.0/worklog/parser.py +180 -0
  17. worklog_for_claude_code-0.1.0/worklog/redact.py +39 -0
  18. worklog_for_claude_code-0.1.0/worklog/report.py +229 -0
  19. worklog_for_claude_code-0.1.0/worklog/services.py +139 -0
  20. worklog_for_claude_code-0.1.0/worklog/store.py +135 -0
  21. worklog_for_claude_code-0.1.0/worklog/web.py +776 -0
  22. worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/PKG-INFO +112 -0
  23. worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/SOURCES.txt +25 -0
  24. worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/dependency_links.txt +1 -0
  25. worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/entry_points.txt +2 -0
  26. worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/requires.txt +11 -0
  27. worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Urvin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: worklog-for-claude-code
3
+ Version: 0.1.0
4
+ Summary: Daily work log and token report across Claude Code accounts and devices, synced through your own private git repo
5
+ Author: Urvin
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/us-urvin/worklog-for-claude-code
8
+ Project-URL: Source, https://github.com/us-urvin/worklog-for-claude-code
9
+ Project-URL: Issues, https://github.com/us-urvin/worklog-for-claude-code/issues
10
+ Project-URL: Changelog, https://github.com/us-urvin/worklog-for-claude-code/blob/main/CHANGELOG.md
11
+ Keywords: claude-code,claude,worklog,time-tracking,token-usage,developer-tools,daily-report
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest>=7; extra == "test"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7; extra == "dev"
33
+ Requires-Dist: ruff==0.16.9; extra == "dev"
34
+ Requires-Dist: build; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # Worklog for Claude Code
38
+
39
+ **See what you did with Claude Code each day, and how many tokens it used. Automatically.**
40
+
41
+ [![CI](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml/badge.svg)](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml)
42
+ [![PyPI](https://img.shields.io/pypi/v/worklog-for-claude-code)](https://pypi.org/project/worklog-for-claude-code/)
43
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
44
+
45
+ > Independent community project, not affiliated with or endorsed by Anthropic.
46
+
47
+ ![Daily view: a 14-day token chart, what you worked on per project, and tokens by account](https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/day.png)
48
+
49
+ ## What is this?
50
+
51
+ You use Claude Code all day. At the end of it, this tool gives you a simple page that shows:
52
+
53
+ - ✅ **What you worked on**: a short summary for each project
54
+ - 📊 **How many tokens you used**: per day, per account and per model
55
+ - 💻 **All in one place**: even if you use several computers or several Claude accounts
56
+
57
+ It works in the background. You don't have to do anything after setup.
58
+
59
+ ![How it works: Claude Code → claude-worklog → your private repo → daily work log](https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/how-it-works.svg)
60
+
61
+ ## Try it first (no setup)
62
+
63
+ ```bash
64
+ pipx run --spec worklog-for-claude-code claude-worklog demo
65
+ ```
66
+
67
+ Then open **http://localhost:8766**. It shows fake example data and doesn't touch your files. Press Ctrl+C to stop.
68
+
69
+ ## Install
70
+
71
+ You need Linux, Python 3.10+ and [Claude Code](https://code.claude.com/docs).
72
+
73
+ ```bash
74
+ # 1. Install
75
+ pipx install worklog-for-claude-code
76
+
77
+ # 2. Create a folder for your data and start the tool
78
+ git init ~/claude-worklog-data
79
+ claude-worklog install-service
80
+ ```
81
+
82
+ **3.** Open **http://worklog.localhost:8765**, fill in the short setup form, then click **Install hook**.
83
+
84
+ Done. Your work log fills up as you use Claude Code.
85
+
86
+ > Using more than one computer? Put the data folder in a **private** GitHub/GitLab repo so all your machines share it. See [Full setup](docs/GUIDE.md#full-setup).
87
+
88
+ ## Is my data private?
89
+
90
+ Yes.
91
+
92
+ - 🔒 **Everything stays on your computer**, plus your own private git repo if you set one up. No cloud service, no tracking.
93
+ - ✂️ **Only short notes are saved**: your prompts (shortened), token counts, file names and commit messages. Never your code, and never Claude's replies.
94
+ - 🧹 **Secrets are removed**: API keys, passwords, tokens and e-mail addresses are cleaned out before saving.
95
+ - 🤖 **Optional AI summaries** use your own Claude account (`claude -p`). Turn them off in Settings to keep everything fully local.
96
+
97
+ [More about privacy →](docs/GUIDE.md#privacy)
98
+
99
+ ## Good to know
100
+
101
+ - Works with **Claude Code only**, not claude.ai chat.
102
+ - **Linux only** for now (it uses systemd).
103
+ - It reads Claude Code's local log files. Their format isn't official, so a Claude Code update could break it for a while.
104
+
105
+ ## More
106
+
107
+ - 📖 [Full guide](docs/GUIDE.md): several computers, several accounts, all commands, uninstalling
108
+ - 🐛 [Report a bug](https://github.com/us-urvin/worklog-for-claude-code/issues/new/choose) · 🔐 [Security](SECURITY.md) · 🤝 [Contributing](CONTRIBUTING.md) · 📝 [Changelog](CHANGELOG.md)
109
+
110
+ ## License
111
+
112
+ [MIT](LICENSE) © 2026 Urvin
@@ -0,0 +1,76 @@
1
+ # Worklog for Claude Code
2
+
3
+ **See what you did with Claude Code each day, and how many tokens it used. Automatically.**
4
+
5
+ [![CI](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml/badge.svg)](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/worklog-for-claude-code)](https://pypi.org/project/worklog-for-claude-code/)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
8
+
9
+ > Independent community project, not affiliated with or endorsed by Anthropic.
10
+
11
+ ![Daily view: a 14-day token chart, what you worked on per project, and tokens by account](https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/day.png)
12
+
13
+ ## What is this?
14
+
15
+ You use Claude Code all day. At the end of it, this tool gives you a simple page that shows:
16
+
17
+ - ✅ **What you worked on**: a short summary for each project
18
+ - 📊 **How many tokens you used**: per day, per account and per model
19
+ - 💻 **All in one place**: even if you use several computers or several Claude accounts
20
+
21
+ It works in the background. You don't have to do anything after setup.
22
+
23
+ ![How it works: Claude Code → claude-worklog → your private repo → daily work log](https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/how-it-works.svg)
24
+
25
+ ## Try it first (no setup)
26
+
27
+ ```bash
28
+ pipx run --spec worklog-for-claude-code claude-worklog demo
29
+ ```
30
+
31
+ Then open **http://localhost:8766**. It shows fake example data and doesn't touch your files. Press Ctrl+C to stop.
32
+
33
+ ## Install
34
+
35
+ You need Linux, Python 3.10+ and [Claude Code](https://code.claude.com/docs).
36
+
37
+ ```bash
38
+ # 1. Install
39
+ pipx install worklog-for-claude-code
40
+
41
+ # 2. Create a folder for your data and start the tool
42
+ git init ~/claude-worklog-data
43
+ claude-worklog install-service
44
+ ```
45
+
46
+ **3.** Open **http://worklog.localhost:8765**, fill in the short setup form, then click **Install hook**.
47
+
48
+ Done. Your work log fills up as you use Claude Code.
49
+
50
+ > Using more than one computer? Put the data folder in a **private** GitHub/GitLab repo so all your machines share it. See [Full setup](docs/GUIDE.md#full-setup).
51
+
52
+ ## Is my data private?
53
+
54
+ Yes.
55
+
56
+ - 🔒 **Everything stays on your computer**, plus your own private git repo if you set one up. No cloud service, no tracking.
57
+ - ✂️ **Only short notes are saved**: your prompts (shortened), token counts, file names and commit messages. Never your code, and never Claude's replies.
58
+ - 🧹 **Secrets are removed**: API keys, passwords, tokens and e-mail addresses are cleaned out before saving.
59
+ - 🤖 **Optional AI summaries** use your own Claude account (`claude -p`). Turn them off in Settings to keep everything fully local.
60
+
61
+ [More about privacy →](docs/GUIDE.md#privacy)
62
+
63
+ ## Good to know
64
+
65
+ - Works with **Claude Code only**, not claude.ai chat.
66
+ - **Linux only** for now (it uses systemd).
67
+ - It reads Claude Code's local log files. Their format isn't official, so a Claude Code update could break it for a while.
68
+
69
+ ## More
70
+
71
+ - 📖 [Full guide](docs/GUIDE.md): several computers, several accounts, all commands, uninstalling
72
+ - 🐛 [Report a bug](https://github.com/us-urvin/worklog-for-claude-code/issues/new/choose) · 🔐 [Security](SECURITY.md) · 🤝 [Contributing](CONTRIBUTING.md) · 📝 [Changelog](CHANGELOG.md)
73
+
74
+ ## License
75
+
76
+ [MIT](LICENSE) © 2026 Urvin
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ # Distribution name on PyPI. The command stays `claude-worklog` and the package stays `worklog`.
7
+ name = "worklog-for-claude-code"
8
+ version = "0.1.0"
9
+ description = "Daily work log and token report across Claude Code accounts and devices, synced through your own private git repo"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ authors = [{ name = "Urvin" }]
15
+ keywords = ["claude-code", "claude", "worklog", "time-tracking", "token-usage", "developer-tools", "daily-report"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Environment :: Web Environment",
20
+ "Intended Audience :: Developers",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Software Development",
29
+ "Topic :: Utilities",
30
+ ]
31
+ # Standard library only, except a TOML parser on Python 3.10 (Ubuntu 22.04).
32
+ dependencies = ['tomli>=2.0; python_version < "3.11"']
33
+
34
+ [project.optional-dependencies]
35
+ test = ["pytest>=7"]
36
+ dev = ["pytest>=7", "ruff==0.16.9", "build"]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/us-urvin/worklog-for-claude-code"
40
+ Source = "https://github.com/us-urvin/worklog-for-claude-code"
41
+ Issues = "https://github.com/us-urvin/worklog-for-claude-code/issues"
42
+ Changelog = "https://github.com/us-urvin/worklog-for-claude-code/blob/main/CHANGELOG.md"
43
+
44
+ [project.scripts]
45
+ claude-worklog = "worklog.cli:main"
46
+
47
+ [tool.setuptools.packages.find]
48
+ include = ["worklog*"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+
53
+ [tool.ruff]
54
+ line-length = 120
55
+ target-version = "py310"
56
+
57
+ [tool.ruff.lint]
58
+ # Pinned explicitly so results don't change when ruff's defaults do.
59
+ select = ["E", "F", "W", "I", "B", "UP"]
60
+
61
+ [tool.ruff.lint.per-file-ignores]
62
+ # web.py renders pages from inline HTML templates; wrapping them would change the markup.
63
+ "worklog/web.py" = ["E501"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,299 @@
1
+ """Several Claude accounts sharing one config folder.
2
+
3
+ Every test runs in a temporary HOME with its own state dir and data repo, so nothing
4
+ touches your real ~/.claude, ~/.claude.json, config, state or ~/claude-worklog-data.
5
+ """
6
+ import io
7
+ import json
8
+ import logging
9
+ import os
10
+ import subprocess
11
+ from datetime import datetime, timedelta, timezone
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ from worklog import cli, collector, identity, store, web
17
+ from worklog.config import Account, Config, ConfigError, dump_config, load_config
18
+ from worklog.hooks import install_hook, installed_events
19
+
20
+ UID_A, EMAIL_A = "11111111-aaaa-4aaa-8aaa-111111111111", "alice.personal@example.com"
21
+ UID_B, EMAIL_B = "22222222-bbbb-4bbb-8bbb-222222222222", "bob.work@example.org"
22
+
23
+
24
+ # ------------------------------------------------------------------ fixtures
25
+ @pytest.fixture
26
+ def home(tmp_path, monkeypatch):
27
+ """A throwaway HOME; state and logs are redirected into it too."""
28
+ h = tmp_path / "home"
29
+ (h / ".claude" / "projects").mkdir(parents=True)
30
+ for var, sub in (("HOME", ""), ("XDG_CONFIG_HOME", ".config"), ("XDG_STATE_HOME", ".local/state"),
31
+ ("XDG_DATA_HOME", ".local/share")):
32
+ monkeypatch.setenv(var, str(h / sub) if sub else str(h))
33
+ monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
34
+ state = h / ".local/state/claude-worklog"
35
+ for mod in (identity, store, cli):
36
+ monkeypatch.setattr(mod, "STATE_DIR", state)
37
+ assert Path.home() == h # guard: never run against the real home
38
+ return h
39
+
40
+
41
+ def login(home: Path, uid: str | None, email: str | None, path: Path | None = None) -> None:
42
+ """Simulate `/login`: write oauthAccount (no credentials) into the fake ~/.claude.json."""
43
+ path = path or home / ".claude.json"
44
+ data = {"numStartups": 3}
45
+ if uid:
46
+ data["oauthAccount"] = {"accountUuid": uid, "emailAddress": email, "displayName": "x"}
47
+ path.write_text(json.dumps(data))
48
+
49
+
50
+ def transcript(home: Path, sid: str, ts: datetime, cwd: Path, tokens: int = 100) -> None:
51
+ iso = ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
52
+ d = home / ".claude" / "projects" / "-work"
53
+ d.mkdir(parents=True, exist_ok=True)
54
+ lines = [
55
+ {"type": "user", "uuid": f"{sid}-u", "sessionId": sid, "timestamp": iso, "cwd": str(cwd),
56
+ "message": {"role": "user", "content": f"prompt for {sid}"}},
57
+ {"type": "assistant", "uuid": f"{sid}-a", "sessionId": sid, "timestamp": iso, "requestId": f"r-{sid}",
58
+ "message": {"id": f"m-{sid}", "model": "claude-x", "content": [],
59
+ "usage": {"input_tokens": tokens, "output_tokens": 1}}},
60
+ ]
61
+ (d / f"{sid}.jsonl").write_text("\n".join(json.dumps(x) for x in lines) + "\n")
62
+
63
+
64
+ @pytest.fixture
65
+ def repo(home):
66
+ r = home / "data"
67
+ r.mkdir()
68
+ subprocess.run(["git", "init", "-q", str(r)], check=True)
69
+ return r
70
+
71
+
72
+ def make_cfg(home: Path, repo: Path, extra: str = "") -> Config:
73
+ p = home / ".config/claude-worklog/config.toml"
74
+ p.parent.mkdir(parents=True, exist_ok=True)
75
+ p.write_text(f'device_name = "dev1"\nstore_repo = "{repo}"\n'
76
+ f'[[accounts]]\nname = "personal"\nconfig_dir = "~/.claude"\n{extra}\n'
77
+ '[report]\nuse_ai = false\n')
78
+ return load_config(p)
79
+
80
+
81
+ def records(repo: Path) -> dict[str, dict]:
82
+ return {str(p.relative_to(repo)): json.loads(p.read_text()) for p in sorted(repo.rglob("records/**/*.json"))}
83
+
84
+
85
+ def start_session(home: Path, sid: str, capsys=None) -> None:
86
+ """Run the real hook entry point as Claude Code would at SessionStart."""
87
+ payload = json.dumps({"session_id": sid, "hook_event_name": "SessionStart", "source": "startup",
88
+ "cwd": str(home), "permission_mode": "default"})
89
+ old = os.sys.stdin
90
+ try:
91
+ os.sys.stdin = io.StringIO(payload)
92
+ assert cli.main(["hook"]) == 0
93
+ finally:
94
+ os.sys.stdin = old
95
+ root = logging.getLogger()
96
+ for h in root.handlers[:]: # _setup_logging adds handlers on every call
97
+ root.removeHandler(h)
98
+ h.close()
99
+
100
+
101
+ # ------------------------------------------------------------------ identity detection
102
+ def test_read_identity_from_fake_claude_json(home):
103
+ login(home, UID_A, EMAIL_A)
104
+ path = identity.identity_path(home / ".claude")
105
+ assert path == home / ".claude.json"
106
+ assert identity.read_identity(path) == identity.Identity(UID_A, EMAIL_A)
107
+ # a custom folder keeps its own file inside it
108
+ assert identity.identity_path(home / ".claude-work") == home / ".claude-work" / ".claude.json"
109
+ # CLAUDE_CONFIG_DIR=~/.claude set explicitly -> the file inside it
110
+ assert identity.identity_path(home / ".claude", explicit=True) == home / ".claude" / ".claude.json"
111
+
112
+
113
+ @pytest.mark.parametrize("content", ['{"oauthAccount": null}', "{broken", "[]",
114
+ '{"oauthAccount": {"emailAddress": "x@y.z"}}', ""])
115
+ def test_read_identity_logged_out_or_damaged(home, content, caplog):
116
+ (home / ".claude.json").write_text(content)
117
+ assert identity.read_identity(home / ".claude.json") is None
118
+ assert identity.read_identity(home / "missing.json") is None
119
+ assert "x@y.z" not in caplog.text
120
+
121
+
122
+ def test_mask_email_and_ref():
123
+ assert identity.mask_email("alice@gmail.com") == "a***@gmail.com"
124
+ assert identity.mask_email(None) == "(no e-mail)"
125
+ r = identity.ref(UID_A)
126
+ assert len(r) == 12 and UID_A not in r
127
+
128
+
129
+ # ------------------------------------------------------------------ session mapping
130
+ def test_session_start_hook_records_account_privately(home, capsys):
131
+ login(home, UID_A, EMAIL_A)
132
+ start_session(home, "sess-1")
133
+ assert capsys.readouterr().out == "" # SessionStart stdout would be injected into Claude's context
134
+ state_file = home / ".local/state/claude-worklog/accounts.json"
135
+ assert (state_file.stat().st_mode & 0o777) == 0o600
136
+ assert identity.session_logins()["sess-1"] == identity.Identity(UID_A, EMAIL_A)
137
+ log_text = (home / ".local/state/claude-worklog/worklog.log").read_text()
138
+ assert EMAIL_A not in log_text and UID_A not in log_text
139
+
140
+
141
+ def test_first_start_wins_and_end_is_only_a_fallback(home):
142
+ a, b = identity.Identity(UID_A, EMAIL_A), identity.Identity(UID_B, EMAIL_B)
143
+ identity.record_session("s1", home / ".claude", a, "start")
144
+ identity.record_session("s1", home / ".claude", b, "start") # e.g. resume after switching
145
+ identity.record_session("s1", home / ".claude", b, "end")
146
+ identity.record_session("s2", home / ".claude", b, "end") # started before the hook existed
147
+ got = identity.session_logins()
148
+ assert got["s1"].account_id == UID_A and got["s2"].account_id == UID_B
149
+ assert {x["email"] for x in identity.detected_logins()} == {EMAIL_A, EMAIL_B}
150
+
151
+
152
+ def test_hook_never_fails(home, monkeypatch):
153
+ monkeypatch.setattr(identity, "record_session", lambda *a, **k: 1 / 0)
154
+ login(home, UID_A, EMAIL_A)
155
+ start_session(home, "boom") # asserts exit code 0
156
+ start_session(home, "") # no session id
157
+ old = os.sys.stdin
158
+ try:
159
+ os.sys.stdin = io.StringIO("not json")
160
+ assert cli.main(["hook"]) == 0
161
+ finally:
162
+ os.sys.stdin = old
163
+
164
+
165
+ def test_old_state_entries_are_pruned(home):
166
+ old = datetime.now().astimezone() - timedelta(days=identity.KEEP_DAYS + 5)
167
+ identity.record_session("ancient", home / ".claude", identity.Identity(UID_A, None), "start", now=old)
168
+ identity.record_session("fresh", home / ".claude", identity.Identity(UID_A, None), "start")
169
+ assert set(identity.session_logins()) == {"fresh"}
170
+
171
+
172
+ # ------------------------------------------------------------------ config
173
+ def test_config_backward_compatible_and_roundtrip(home, repo):
174
+ cfg = make_cfg(home, repo) # the 0.1.0 format, no match_* keys
175
+ assert cfg.resolve_account(home / ".claude", UID_B, EMAIL_B).name == "personal"
176
+ cfg = make_cfg(home, repo,
177
+ f'[[accounts]]\nname = "work"\nconfig_dir = "~/.claude"\nmatch_account_id = "{UID_B}"\n'
178
+ '[[accounts]]\nname = "club"\nconfig_dir = "~/.claude"\nmatch_email = "Carol@Example.com"\n')
179
+ d = home / ".claude"
180
+ assert cfg.default_account(d).name == "personal"
181
+ assert cfg.resolve_account(d, UID_B, None).name == "work"
182
+ assert cfg.resolve_account(d, "other", "carol@example.com").name == "club" # e-mail match ignores case
183
+ assert cfg.resolve_account(d, UID_A, EMAIL_A).name == "personal"
184
+ p = home / "roundtrip.toml"
185
+ p.write_text(dump_config(cfg))
186
+ assert load_config(p).accounts == cfg.accounts
187
+
188
+
189
+ def test_config_rejects_ambiguous_shared_folder(home, repo):
190
+ with pytest.raises(ConfigError, match="share"):
191
+ make_cfg(home, repo, '[[accounts]]\nname = "work"\nconfig_dir = "~/.claude"\n')
192
+ with pytest.raises(ConfigError, match="same login"):
193
+ make_cfg(home, repo, f'[[accounts]]\nname = "a"\nconfig_dir = "~/.claude"\nmatch_account_id = "{UID_B}"\n'
194
+ f'[[accounts]]\nname = "b"\nconfig_dir = "~/.claude"\nmatch_account_id = "{UID_B}"\n')
195
+
196
+
197
+ # ------------------------------------------------------------------ collecting
198
+ def test_switching_accounts_mid_day(home, repo, capsys):
199
+ cfg = make_cfg(home, repo, f'[[accounts]]\nname = "work"\nconfig_dir = "~/.claude"\nmatch_account_id = "{UID_B}"\n')
200
+ now = datetime.now().astimezone().replace(microsecond=0)
201
+ login(home, UID_A, EMAIL_A)
202
+ start_session(home, "morning")
203
+ transcript(home, "morning", now, home, tokens=100)
204
+ login(home, UID_B, EMAIL_B) # /logout, /login with the work account
205
+ start_session(home, "afternoon")
206
+ transcript(home, "afternoon", now, home, tokens=900)
207
+ transcript(home, "legacy", now, home, tokens=5) # no hook data: falls back to the folder default
208
+
209
+ assert collector.collect(cfg, [now.date()]) == 3
210
+ recs = records(repo)
211
+ day = now.date().isoformat()
212
+ assert recs[f"records/{day}/dev1/personal/morning.json"]["account"] == "personal"
213
+ assert recs[f"records/{day}/dev1/work/afternoon.json"]["account"] == "work"
214
+ assert recs[f"records/{day}/dev1/personal/legacy.json"]["tokens"]["input"] == 5
215
+ assert len(recs) == 3
216
+
217
+
218
+ def test_no_email_or_account_id_in_generated_data(home, repo, capsys):
219
+ cfg = make_cfg(home, repo, f'[[accounts]]\nname = "work"\nconfig_dir = "~/.claude"\nmatch_email = "{EMAIL_B}"\n')
220
+ now = datetime.now().astimezone()
221
+ login(home, UID_B, EMAIL_B)
222
+ start_session(home, "s1")
223
+ transcript(home, "s1", now, home)
224
+ collector.collect(cfg, [now.date()])
225
+ from worklog.report import build_report
226
+ build_report(cfg, now.date(), use_ai=False, write=True)
227
+ blob = "".join(p.read_text() for p in repo.rglob("*") if p.is_file() and ".git" not in p.parts)
228
+ blob += subprocess.run(["git", "-C", str(repo), "log", "-p"], capture_output=True, text=True).stdout
229
+ assert "work" in blob
230
+ for secret in (EMAIL_B, UID_B, "bob.work", "example.org"):
231
+ assert secret not in blob
232
+
233
+
234
+ def test_naming_a_login_later_moves_its_records(home, repo, capsys):
235
+ day = datetime.now().astimezone()
236
+ d = day.date().isoformat()
237
+ # A record from before accounts were tracked, which must stay untouched.
238
+ old_rel = f"records/{d}/dev1/personal/untracked.json"
239
+ (repo / old_rel).parent.mkdir(parents=True)
240
+ (repo / old_rel).write_text('{"account": "personal", "session_id": "untracked"}\n')
241
+
242
+ cfg = make_cfg(home, repo)
243
+ login(home, UID_B, EMAIL_B)
244
+ start_session(home, "s-work")
245
+ transcript(home, "s-work", day, home)
246
+ collector.collect(cfg, [day.date()])
247
+ assert f"records/{d}/dev1/personal/s-work.json" in records(repo) # unnamed login -> default
248
+
249
+ # The user names that login "work" in the UI.
250
+ cfg = make_cfg(home, repo, f'[[accounts]]\nname = "work"\nconfig_dir = "~/.claude"\nmatch_account_id = "{UID_B}"\n')
251
+ collector.collect(cfg, [day.date()])
252
+ recs = records(repo)
253
+ assert f"records/{d}/dev1/work/s-work.json" in recs
254
+ assert f"records/{d}/dev1/personal/s-work.json" not in recs # moved, not duplicated
255
+ assert recs[old_rel] == {"account": "personal", "session_id": "untracked"}
256
+
257
+
258
+ # ------------------------------------------------------------------ hooks install
259
+ def test_install_hook_adds_both_events_idempotently(home):
260
+ acct = Account("personal", home / ".claude")
261
+ settings = home / ".claude/settings.json"
262
+ other = {"type": "command", "command": "some-other-tool"}
263
+ settings.write_text(json.dumps({"model": "x", "hooks": {
264
+ "SessionStart": [{"hooks": [other]}],
265
+ "SessionEnd": [{"hooks": [{"type": "command", "command": "/usr/bin/claude-worklog hook"}]}]}}))
266
+ assert installed_events(acct) == {"SessionEnd"}
267
+ ok, msg = install_hook(acct)
268
+ assert ok and "SessionStart" in msg
269
+ data = json.loads(settings.read_text())
270
+ assert data["model"] == "x" and data["hooks"]["SessionStart"][0]["hooks"] == [other]
271
+ assert installed_events(acct) == {"SessionStart", "SessionEnd"}
272
+ assert len(list(settings.parent.glob("settings.json.bak-*"))) == 1
273
+ before = settings.read_text()
274
+ assert install_hook(acct) == (True, "personal: hooks already installed")
275
+ assert settings.read_text() == before
276
+
277
+
278
+ # ------------------------------------------------------------------ web UI
279
+ def test_detected_accounts_panel_masks_and_names(home, repo, monkeypatch):
280
+ cfg = make_cfg(home, repo)
281
+ identity.record_session("s", home / ".claude", identity.Identity(UID_B, "<b>" + EMAIL_B), "start")
282
+ html_out = web._detected_section(cfg, '<input type="hidden" name="csrf" value="t">')
283
+ assert EMAIL_B not in html_out and UID_B not in html_out and "<b><" not in html_out
284
+ assert "&lt;***@example.org" in html_out and "folder default" in html_out
285
+
286
+ saved = []
287
+ monkeypatch.setattr(web, "save_config", lambda c: saved.append(c))
288
+ app = web.App(8765)
289
+ ref = identity.ref(UID_B)
290
+ loc, err, _ = app._account_name(cfg, {"ref": ref, "dir": "~/.claude", "name": "work"})
291
+ assert err is None and loc == "/settings?ok=named"
292
+ work = saved[-1].account("work")
293
+ assert work.match_account_id == UID_B and work.config_dir == home / ".claude"
294
+ # bad input is rejected without saving
295
+ for form in ({"ref": "nope", "dir": "~/.claude", "name": "x"},
296
+ {"ref": ref, "dir": "~/elsewhere", "name": "x"},
297
+ {"ref": ref, "dir": "~/.claude", "name": "bad name!"}):
298
+ n = len(saved)
299
+ assert app._account_name(cfg, form)[1] and len(saved) == n