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.
- worklog_for_claude_code-0.1.0/LICENSE +21 -0
- worklog_for_claude_code-0.1.0/PKG-INFO +112 -0
- worklog_for_claude_code-0.1.0/README.md +76 -0
- worklog_for_claude_code-0.1.0/pyproject.toml +63 -0
- worklog_for_claude_code-0.1.0/setup.cfg +4 -0
- worklog_for_claude_code-0.1.0/tests/test_accounts.py +299 -0
- worklog_for_claude_code-0.1.0/tests/test_demo.py +145 -0
- worklog_for_claude_code-0.1.0/tests/test_worklog.py +54 -0
- worklog_for_claude_code-0.1.0/worklog/__init__.py +3 -0
- worklog_for_claude_code-0.1.0/worklog/cli.py +249 -0
- worklog_for_claude_code-0.1.0/worklog/collector.py +197 -0
- worklog_for_claude_code-0.1.0/worklog/config.py +294 -0
- worklog_for_claude_code-0.1.0/worklog/demo.py +214 -0
- worklog_for_claude_code-0.1.0/worklog/hooks.py +78 -0
- worklog_for_claude_code-0.1.0/worklog/identity.py +238 -0
- worklog_for_claude_code-0.1.0/worklog/parser.py +180 -0
- worklog_for_claude_code-0.1.0/worklog/redact.py +39 -0
- worklog_for_claude_code-0.1.0/worklog/report.py +229 -0
- worklog_for_claude_code-0.1.0/worklog/services.py +139 -0
- worklog_for_claude_code-0.1.0/worklog/store.py +135 -0
- worklog_for_claude_code-0.1.0/worklog/web.py +776 -0
- worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/PKG-INFO +112 -0
- worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/SOURCES.txt +25 -0
- worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/dependency_links.txt +1 -0
- worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/entry_points.txt +2 -0
- worklog_for_claude_code-0.1.0/worklog_for_claude_code.egg-info/requires.txt +11 -0
- 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
|
+
[](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml)
|
|
42
|
+
[](https://pypi.org/project/worklog-for-claude-code/)
|
|
43
|
+
[](LICENSE)
|
|
44
|
+
|
|
45
|
+
> Independent community project, not affiliated with or endorsed by Anthropic.
|
|
46
|
+
|
|
47
|
+

|
|
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
|
+

|
|
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
|
+
[](https://github.com/us-urvin/worklog-for-claude-code/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/worklog-for-claude-code/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
> Independent community project, not affiliated with or endorsed by Anthropic.
|
|
10
|
+
|
|
11
|
+

|
|
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
|
+

|
|
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,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 "<***@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
|