worklog-for-claude-code 0.2.0__tar.gz → 0.3.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 (45) hide show
  1. worklog_for_claude_code-0.3.0/PKG-INFO +130 -0
  2. worklog_for_claude_code-0.3.0/README.md +91 -0
  3. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/pyproject.toml +7 -4
  4. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/tests/test_accounts.py +120 -30
  5. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/tests/test_chats.py +77 -7
  6. worklog_for_claude_code-0.3.0/tests/test_checks.py +103 -0
  7. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/tests/test_demo.py +25 -1
  8. worklog_for_claude_code-0.3.0/tests/test_platforms.py +284 -0
  9. worklog_for_claude_code-0.3.0/tests/test_updates.py +112 -0
  10. worklog_for_claude_code-0.3.0/tests/test_viz.py +78 -0
  11. worklog_for_claude_code-0.3.0/tests/test_watcher.py +219 -0
  12. worklog_for_claude_code-0.3.0/tests/test_worklog.py +101 -0
  13. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/__init__.py +1 -1
  14. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/chats.py +63 -6
  15. worklog_for_claude_code-0.3.0/worklog/checks.py +209 -0
  16. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/cli.py +87 -8
  17. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/collector.py +32 -8
  18. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/config.py +52 -6
  19. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/demo.py +28 -3
  20. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/hooks.py +25 -2
  21. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/identity.py +21 -19
  22. worklog_for_claude_code-0.3.0/worklog/locks.py +60 -0
  23. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/parser.py +14 -3
  24. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/report.py +65 -12
  25. worklog_for_claude_code-0.3.0/worklog/services.py +513 -0
  26. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/store.py +18 -20
  27. worklog_for_claude_code-0.3.0/worklog/updates.py +193 -0
  28. worklog_for_claude_code-0.3.0/worklog/viz.py +199 -0
  29. worklog_for_claude_code-0.3.0/worklog/watcher.py +493 -0
  30. worklog_for_claude_code-0.3.0/worklog/web.py +1537 -0
  31. worklog_for_claude_code-0.3.0/worklog_for_claude_code.egg-info/PKG-INFO +130 -0
  32. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog_for_claude_code.egg-info/SOURCES.txt +10 -0
  33. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog_for_claude_code.egg-info/requires.txt +1 -1
  34. worklog_for_claude_code-0.2.0/PKG-INFO +0 -114
  35. worklog_for_claude_code-0.2.0/README.md +0 -78
  36. worklog_for_claude_code-0.2.0/tests/test_worklog.py +0 -54
  37. worklog_for_claude_code-0.2.0/worklog/services.py +0 -139
  38. worklog_for_claude_code-0.2.0/worklog/web.py +0 -965
  39. worklog_for_claude_code-0.2.0/worklog_for_claude_code.egg-info/PKG-INFO +0 -114
  40. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/LICENSE +0 -0
  41. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/setup.cfg +0 -0
  42. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog/redact.py +0 -0
  43. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog_for_claude_code.egg-info/dependency_links.txt +0 -0
  44. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog_for_claude_code.egg-info/entry_points.txt +0 -0
  45. {worklog_for_claude_code-0.2.0 → worklog_for_claude_code-0.3.0}/worklog_for_claude_code.egg-info/top_level.txt +0 -0
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: worklog-for-claude-code
3
+ Version: 0.3.0
4
+ Summary: See what you did with Claude Code each day and how many tokens it used: an automatic daily work log across accounts and devices, private by default
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: Documentation, https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GETTING-STARTED.md
11
+ Project-URL: Changelog, https://github.com/us-urvin/worklog-for-claude-code/blob/main/CHANGELOG.md
12
+ Keywords: claude-code,claude,claude-ai,anthropic,worklog,time-tracking,token-usage,token-tracker,usage-tracker,standup,developer-tools,daily-report,productivity
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Environment :: Web Environment
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3 :: Only
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Topic :: Software Development
27
+ Classifier: Topic :: Utilities
28
+ Requires-Python: >=3.10
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
32
+ Provides-Extra: test
33
+ Requires-Dist: pytest>=7; extra == "test"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=7; extra == "dev"
36
+ Requires-Dist: ruff==0.17.0; extra == "dev"
37
+ Requires-Dist: build; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # Worklog for Claude Code
41
+
42
+ **A diary of your Claude Code work, written for you. See what you did each day and how many tokens it used.**
43
+
44
+ [![Downloads](https://static.pepy.tech/badge/worklog-for-claude-code)](https://pepy.tech/project/worklog-for-claude-code)
45
+ [![PyPI](https://img.shields.io/pypi/v/worklog-for-claude-code)](https://pypi.org/project/worklog-for-claude-code/)
46
+ [![GitHub stars](https://img.shields.io/github/stars/us-urvin/worklog-for-claude-code?style=flat)](https://github.com/us-urvin/worklog-for-claude-code/stargazers)
47
+ [![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)
48
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/us-urvin/worklog-for-claude-code/blob/main/LICENSE)
49
+
50
+ ![Worklog: today's tokens, sessions and time, a 14-day chart and what you worked on](https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/demo.gif)
51
+
52
+ ▶️ **[Watch the 90-second video](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/media/worklog-demo.mp4)**
53
+
54
+ ## What it does
55
+
56
+ - ✅ **Writes down what you worked on**, project by project, every day. Claude writes the summary.
57
+ - 📊 **Shows how many tokens you used**, per day, account and model.
58
+ - 👥 **Handles several Claude accounts and computers**, even if you switch accounts with a one-click app.
59
+ - 🔒 **Stays private**: everything lives on your computer (and your own private git repo, if you want).
60
+ - 💻 **Runs on Linux, macOS and Windows**, quietly in the background.
61
+
62
+ ## Try it (nothing is installed)
63
+
64
+ ```bash
65
+ pipx run --spec worklog-for-claude-code claude-worklog demo
66
+ ```
67
+
68
+ Open **http://localhost:8766** to see it with made-up data. Press **Ctrl+C** to stop.
69
+
70
+ ## Install in 3 steps
71
+
72
+ You need **Python 3.10+**, **git** and **[Claude Code](https://code.claude.com/docs)**. No pipx yet? See the [beginner guide](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GETTING-STARTED.md).
73
+
74
+ **1. Install**
75
+
76
+ ```bash
77
+ pipx install worklog-for-claude-code
78
+ ```
79
+
80
+ **2. Create a folder for your log and start Worklog**
81
+
82
+ ```bash
83
+ git init ~/claude-worklog-data # Windows PowerShell: git init "$HOME\claude-worklog-data"
84
+ claude-worklog install-service
85
+ ```
86
+
87
+ **3. Open http://worklog.localhost:8765** (or http://localhost:8765) and fill in the short form.
88
+
89
+ A checklist then shows anything still missing, with the exact command for your computer. When everything is ticked, it disappears. That's it: use Claude Code as usual.
90
+
91
+ ## A closer look
92
+
93
+ <table><tr>
94
+ <td width="50%"><img src="https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/insights.png" alt="When you worked, the last 5 weeks and today's projects"><br>When you worked, your streak, and where the time went.</td>
95
+ <td width="50%"><img src="https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/checklist.png" alt="Setup checklist"><br>A setup checklist with steps for your system.</td>
96
+ </tr><tr>
97
+ <td width="50%"><img src="https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/notice.png" alt="New account found"><br>New accounts are noticed for you.</td>
98
+ <td width="50%"><img src="https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/update.png" alt="A new version is out: Update now"><br>Updates in one click.</td>
99
+ </tr></table>
100
+
101
+ ## Privacy
102
+
103
+ - Only short notes are saved: the first 300 characters of your prompts, token counts, file names and commit messages. Never your files or Claude's replies.
104
+ - Common secrets (API keys, passwords, e-mails) are blanked out before saving. This is best effort, so keep your data repo **private**.
105
+ - Summaries are written by your own Claude account. Turn them off in Settings to stay fully offline.
106
+ - The only other request: once a day, Worklog asks pypi.org whether a new version is out. Nothing about you is sent, and you can turn it off.
107
+
108
+ [Privacy in detail →](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#privacy)
109
+
110
+ ## Questions
111
+
112
+ **It says Sonnet, but I use Opus?** The "By model" list shows what Claude Code really used. To get better summaries, pick **Opus** in Settings → Summaries.
113
+
114
+ **Several accounts or computers?** Settings walks you through it. For several computers, see [Full setup](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#full-setup).
115
+
116
+ **How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade worklog-for-claude-code`.
117
+
118
+ **Something isn't working?** Run `claude-worklog doctor`, or see [Troubleshooting](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md).
119
+
120
+ **How do I remove it?** `claude-worklog uninstall-service`, then `pipx uninstall worklog-for-claude-code`. Your log stays.
121
+
122
+ ## Like it? ⭐
123
+
124
+ If Worklog saves you time, please **[star it on GitHub](https://github.com/us-urvin/worklog-for-claude-code)**. It helps others find it.
125
+
126
+ 📘 [Beginner guide](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GETTING-STARTED.md) · 📖 [Full guide](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md) · 🐛 [Report a bug](https://github.com/us-urvin/worklog-for-claude-code/issues/new/choose) · 📝 [Changelog](https://github.com/us-urvin/worklog-for-claude-code/blob/main/CHANGELOG.md)
127
+
128
+ ---
129
+
130
+ [MIT](https://github.com/us-urvin/worklog-for-claude-code/blob/main/LICENSE) © 2026 Urvin · Not affiliated with or endorsed by Anthropic.
@@ -0,0 +1,91 @@
1
+ # Worklog for Claude Code
2
+
3
+ **A diary of your Claude Code work, written for you. See what you did each day and how many tokens it used.**
4
+
5
+ [![Downloads](https://static.pepy.tech/badge/worklog-for-claude-code)](https://pepy.tech/project/worklog-for-claude-code)
6
+ [![PyPI](https://img.shields.io/pypi/v/worklog-for-claude-code)](https://pypi.org/project/worklog-for-claude-code/)
7
+ [![GitHub stars](https://img.shields.io/github/stars/us-urvin/worklog-for-claude-code?style=flat)](https://github.com/us-urvin/worklog-for-claude-code/stargazers)
8
+ [![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)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/us-urvin/worklog-for-claude-code/blob/main/LICENSE)
10
+
11
+ ![Worklog: today's tokens, sessions and time, a 14-day chart and what you worked on](https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/demo.gif)
12
+
13
+ ▶️ **[Watch the 90-second video](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/media/worklog-demo.mp4)**
14
+
15
+ ## What it does
16
+
17
+ - ✅ **Writes down what you worked on**, project by project, every day. Claude writes the summary.
18
+ - 📊 **Shows how many tokens you used**, per day, account and model.
19
+ - 👥 **Handles several Claude accounts and computers**, even if you switch accounts with a one-click app.
20
+ - 🔒 **Stays private**: everything lives on your computer (and your own private git repo, if you want).
21
+ - 💻 **Runs on Linux, macOS and Windows**, quietly in the background.
22
+
23
+ ## Try it (nothing is installed)
24
+
25
+ ```bash
26
+ pipx run --spec worklog-for-claude-code claude-worklog demo
27
+ ```
28
+
29
+ Open **http://localhost:8766** to see it with made-up data. Press **Ctrl+C** to stop.
30
+
31
+ ## Install in 3 steps
32
+
33
+ You need **Python 3.10+**, **git** and **[Claude Code](https://code.claude.com/docs)**. No pipx yet? See the [beginner guide](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GETTING-STARTED.md).
34
+
35
+ **1. Install**
36
+
37
+ ```bash
38
+ pipx install worklog-for-claude-code
39
+ ```
40
+
41
+ **2. Create a folder for your log and start Worklog**
42
+
43
+ ```bash
44
+ git init ~/claude-worklog-data # Windows PowerShell: git init "$HOME\claude-worklog-data"
45
+ claude-worklog install-service
46
+ ```
47
+
48
+ **3. Open http://worklog.localhost:8765** (or http://localhost:8765) and fill in the short form.
49
+
50
+ A checklist then shows anything still missing, with the exact command for your computer. When everything is ticked, it disappears. That's it: use Claude Code as usual.
51
+
52
+ ## A closer look
53
+
54
+ <table><tr>
55
+ <td width="50%"><img src="https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/insights.png" alt="When you worked, the last 5 weeks and today's projects"><br>When you worked, your streak, and where the time went.</td>
56
+ <td width="50%"><img src="https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/checklist.png" alt="Setup checklist"><br>A setup checklist with steps for your system.</td>
57
+ </tr><tr>
58
+ <td width="50%"><img src="https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/notice.png" alt="New account found"><br>New accounts are noticed for you.</td>
59
+ <td width="50%"><img src="https://raw.githubusercontent.com/us-urvin/worklog-for-claude-code/main/docs/images/update.png" alt="A new version is out: Update now"><br>Updates in one click.</td>
60
+ </tr></table>
61
+
62
+ ## Privacy
63
+
64
+ - Only short notes are saved: the first 300 characters of your prompts, token counts, file names and commit messages. Never your files or Claude's replies.
65
+ - Common secrets (API keys, passwords, e-mails) are blanked out before saving. This is best effort, so keep your data repo **private**.
66
+ - Summaries are written by your own Claude account. Turn them off in Settings to stay fully offline.
67
+ - The only other request: once a day, Worklog asks pypi.org whether a new version is out. Nothing about you is sent, and you can turn it off.
68
+
69
+ [Privacy in detail →](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#privacy)
70
+
71
+ ## Questions
72
+
73
+ **It says Sonnet, but I use Opus?** The "By model" list shows what Claude Code really used. To get better summaries, pick **Opus** in Settings → Summaries.
74
+
75
+ **Several accounts or computers?** Settings walks you through it. For several computers, see [Full setup](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#full-setup).
76
+
77
+ **How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade worklog-for-claude-code`.
78
+
79
+ **Something isn't working?** Run `claude-worklog doctor`, or see [Troubleshooting](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md).
80
+
81
+ **How do I remove it?** `claude-worklog uninstall-service`, then `pipx uninstall worklog-for-claude-code`. Your log stays.
82
+
83
+ ## Like it? ⭐
84
+
85
+ If Worklog saves you time, please **[star it on GitHub](https://github.com/us-urvin/worklog-for-claude-code)**. It helps others find it.
86
+
87
+ 📘 [Beginner guide](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GETTING-STARTED.md) · 📖 [Full guide](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md) · 🐛 [Report a bug](https://github.com/us-urvin/worklog-for-claude-code/issues/new/choose) · 📝 [Changelog](https://github.com/us-urvin/worklog-for-claude-code/blob/main/CHANGELOG.md)
88
+
89
+ ---
90
+
91
+ [MIT](https://github.com/us-urvin/worklog-for-claude-code/blob/main/LICENSE) © 2026 Urvin · Not affiliated with or endorsed by Anthropic.
@@ -5,19 +5,21 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  # Distribution name on PyPI. The command stays `claude-worklog` and the package stays `worklog`.
7
7
  name = "worklog-for-claude-code"
8
- version = "0.2.0"
9
- description = "Daily work log and token report across Claude Code accounts and devices, plus imported claude.ai chats, synced through your own private git repo"
8
+ version = "0.3.0"
9
+ description = "See what you did with Claude Code each day and how many tokens it used: an automatic daily work log across accounts and devices, private by default"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
12
12
  license = "MIT"
13
13
  license-files = ["LICENSE"]
14
14
  authors = [{ name = "Urvin" }]
15
- keywords = ["claude-code", "claude", "claude-ai", "worklog", "time-tracking", "token-usage", "developer-tools", "daily-report"]
15
+ keywords = ["claude-code", "claude", "claude-ai", "anthropic", "worklog", "time-tracking", "token-usage", "token-tracker", "usage-tracker", "standup", "developer-tools", "daily-report", "productivity"]
16
16
  classifiers = [
17
17
  "Development Status :: 4 - Beta",
18
18
  "Environment :: Console",
19
19
  "Environment :: Web Environment",
20
20
  "Intended Audience :: Developers",
21
+ "Operating System :: MacOS",
22
+ "Operating System :: Microsoft :: Windows",
21
23
  "Operating System :: POSIX :: Linux",
22
24
  "Programming Language :: Python :: 3",
23
25
  "Programming Language :: Python :: 3 :: Only",
@@ -33,12 +35,13 @@ dependencies = ['tomli>=2.0; python_version < "3.11"']
33
35
 
34
36
  [project.optional-dependencies]
35
37
  test = ["pytest>=7"]
36
- dev = ["pytest>=7", "ruff==0.16.9", "build"]
38
+ dev = ["pytest>=7", "ruff==0.17.0", "build"]
37
39
 
38
40
  [project.urls]
39
41
  Homepage = "https://github.com/us-urvin/worklog-for-claude-code"
40
42
  Source = "https://github.com/us-urvin/worklog-for-claude-code"
41
43
  Issues = "https://github.com/us-urvin/worklog-for-claude-code/issues"
44
+ Documentation = "https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GETTING-STARTED.md"
42
45
  Changelog = "https://github.com/us-urvin/worklog-for-claude-code/blob/main/CHANGELOG.md"
43
46
 
44
47
  [project.scripts]
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
 
14
14
  import pytest
15
15
 
16
- from worklog import cli, collector, identity, store, web
16
+ from worklog import cli, collector, identity, web
17
17
  from worklog.config import Account, Config, ConfigError, dump_config, load_config
18
18
  from worklog.hooks import install_hook, installed_events
19
19
 
@@ -21,23 +21,7 @@ UID_A, EMAIL_A = "11111111-aaaa-4aaa-8aaa-111111111111", "alice.personal@example
21
21
  UID_B, EMAIL_B = "22222222-bbbb-4bbb-8bbb-222222222222", "bob.work@example.org"
22
22
 
23
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
-
24
+ # ------------------------------------------------------------------ helpers (fixtures: conftest.py)
41
25
  def login(home: Path, uid: str | None, email: str | None, path: Path | None = None) -> None:
42
26
  """Simulate `/login`: write oauthAccount (no credentials) into the fake ~/.claude.json."""
43
27
  path = path or home / ".claude.json"
@@ -61,25 +45,18 @@ def transcript(home: Path, sid: str, ts: datetime, cwd: Path, tokens: int = 100)
61
45
  (d / f"{sid}.jsonl").write_text("\n".join(json.dumps(x) for x in lines) + "\n")
62
46
 
63
47
 
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
48
  def make_cfg(home: Path, repo: Path, extra: str = "") -> Config:
73
49
  p = home / ".config/claude-worklog/config.toml"
74
50
  p.parent.mkdir(parents=True, exist_ok=True)
75
- p.write_text(f'device_name = "dev1"\nstore_repo = "{repo}"\n'
51
+ p.write_text(f'device_name = "dev1"\nstore_repo = {json.dumps(str(repo))}\n'
76
52
  f'[[accounts]]\nname = "personal"\nconfig_dir = "~/.claude"\n{extra}\n'
77
53
  '[report]\nuse_ai = false\n')
78
54
  return load_config(p)
79
55
 
80
56
 
81
57
  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"))}
58
+ return {p.relative_to(repo).as_posix(): json.loads(p.read_text(encoding="utf-8"))
59
+ for p in sorted(repo.rglob("records/**/*.json"))}
83
60
 
84
61
 
85
62
  def start_session(home: Path, sid: str, capsys=None) -> None:
@@ -132,7 +109,8 @@ def test_session_start_hook_records_account_privately(home, capsys):
132
109
  start_session(home, "sess-1")
133
110
  assert capsys.readouterr().out == "" # SessionStart stdout would be injected into Claude's context
134
111
  state_file = home / ".local/state/claude-worklog/accounts.json"
135
- assert (state_file.stat().st_mode & 0o777) == 0o600
112
+ if os.name != "nt": # Windows has no Unix modes; files there are private to the user profile
113
+ assert (state_file.stat().st_mode & 0o777) == 0o600
136
114
  assert identity.session_logins()["sess-1"] == identity.Identity(UID_A, EMAIL_A)
137
115
  log_text = (home / ".local/state/claude-worklog/worklog.log").read_text()
138
116
  assert EMAIL_A not in log_text and UID_A not in log_text
@@ -281,7 +259,7 @@ def test_detected_accounts_panel_masks_and_names(home, repo, monkeypatch):
281
259
  identity.record_session("s", home / ".claude", identity.Identity(UID_B, "<b>" + EMAIL_B), "start")
282
260
  html_out = web._detected_section(cfg, '<input type="hidden" name="csrf" value="t">')
283
261
  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
262
+ assert "&lt;***@example.org" in html_out and "Counted as personal" in html_out # only login in its folder
285
263
 
286
264
  saved = []
287
265
  monkeypatch.setattr(web, "save_config", lambda c: saved.append(c))
@@ -297,3 +275,115 @@ def test_detected_accounts_panel_masks_and_names(home, repo, monkeypatch):
297
275
  {"ref": ref, "dir": "~/.claude", "name": "bad name!"}):
298
276
  n = len(saved)
299
277
  assert app._account_name(cfg, form)[1] and len(saved) == n
278
+
279
+
280
+ # ------------------------------------------------------------------ 0.3.0: summary model, re-filing history
281
+ def _wait_for_job(app, timeout=10.0):
282
+ import time
283
+ deadline = time.monotonic() + timeout
284
+ while app.jobs.snapshot().get("running"):
285
+ assert time.monotonic() < deadline, "background job did not finish"
286
+ time.sleep(0.05)
287
+ return app.jobs.snapshot()
288
+
289
+
290
+ def test_naming_a_login_refiles_older_days_too(home, repo, monkeypatch):
291
+ """Naming a login must fix every day Claude Code still has, not just the last two."""
292
+ cfg = make_cfg(home, repo)
293
+ old = (datetime.now().astimezone() - timedelta(days=5)).replace(hour=12, minute=0, second=0, microsecond=0)
294
+ d = old.date().isoformat()
295
+ login(home, UID_B, EMAIL_B)
296
+ start_session(home, "s-old")
297
+ transcript(home, "s-old", old, home)
298
+ collector.collect(cfg, [old.date()])
299
+ assert f"records/{d}/dev1/personal/s-old.json" in records(repo) # unnamed login -> folder default
300
+
301
+ from worklog.config import save_config
302
+ monkeypatch.setattr(web, "save_config", lambda c: save_config(c, c.path))
303
+ monkeypatch.setattr(web, "_try_config", lambda: load_config(cfg.path))
304
+ app = web.App(8765)
305
+ loc, err, _ = app.post("/accounts/name", {"ref": identity.ref(UID_B), "dir": "~/.claude", "name": "work"})
306
+ assert err is None and loc.startswith("/settings")
307
+ state = _wait_for_job(app)
308
+ assert state["ok"], state["message"]
309
+ recs = records(repo)
310
+ assert f"records/{d}/dev1/work/s-old.json" in recs
311
+ assert f"records/{d}/dev1/personal/s-old.json" not in recs
312
+
313
+
314
+ def test_summary_uses_the_configured_model(home, repo, monkeypatch):
315
+ from worklog import report
316
+ cfg = make_cfg(home, repo)
317
+ calls = []
318
+
319
+ def fake_run(cmd, **kw):
320
+ calls.append(cmd)
321
+ return subprocess.CompletedProcess(cmd, 0, "- did a thing\n", "")
322
+ monkeypatch.setattr(report.shutil, "which", lambda _: "/usr/bin/claude")
323
+ monkeypatch.setattr(report.subprocess, "run", fake_run)
324
+ p = report.ProjectDay("k", "proj", "git", None)
325
+ p.prompts.append(("09:00", "fix the bug"))
326
+
327
+ assert report.ai_summary(p, datetime.now().date(), cfg) == ["- did a thing"]
328
+ assert "--model" not in calls[-1] # default: the account's own default model
329
+
330
+ cfg.summary_model = "opus"
331
+ report.ai_summary(p, datetime.now().date(), cfg)
332
+ i = calls[-1].index("--model")
333
+ assert calls[-1][i + 1] == "opus"
334
+
335
+
336
+ def test_summary_model_config_roundtrip_and_validation(home, repo):
337
+ cfg = make_cfg(home, repo)
338
+ assert cfg.summary_model == ""
339
+ cfg.summary_model = "claude-opus-5-5"
340
+ cfg.path.write_text(dump_config(cfg))
341
+ assert load_config(cfg.path).summary_model == "claude-opus-5-5"
342
+ for bad in ("--dangerously-skip-permissions", "opus 5", "a" * 101, "op;us"):
343
+ cfg.path.write_text(dump_config(cfg).replace('"claude-opus-5-5"', json.dumps(bad)))
344
+ with pytest.raises(ConfigError):
345
+ load_config(cfg.path)
346
+
347
+
348
+ def test_settings_form_saves_summary_model(home, repo, monkeypatch):
349
+ cfg = make_cfg(home, repo)
350
+ saved = []
351
+ monkeypatch.setattr(web, "save_config", lambda c: saved.append(c))
352
+ monkeypatch.setattr(web, "_try_config", lambda: load_config(cfg.path))
353
+ app = web.App(8765)
354
+ base = {"summarizer": "personal", "claude_bin": "claude", "use_ai": "1"}
355
+ assert app.post("/settings/report", {**base, "model": "opus"})[1] is None
356
+ assert saved[-1].summary_model == "opus"
357
+ assert app.post("/settings/report", {**base, "model": "custom", "model_custom": "claude-opus-5-5"})[1] is None
358
+ assert saved[-1].summary_model == "claude-opus-5-5"
359
+ assert app.post("/settings/report", {**base, "model": ""})[1] is None
360
+ assert saved[-1].summary_model == ""
361
+ n = len(saved)
362
+ assert app.post("/settings/report", {**base, "model": "custom", "model_custom": "--evil"})[1]
363
+ assert len(saved) == n
364
+
365
+
366
+ def test_doctor_shows_default_and_summary_models(home, repo, capsys, monkeypatch):
367
+ cfg = make_cfg(home, repo)
368
+ cfg.use_ai, cfg.summary_model = True, "opus"
369
+ cfg.path.write_text(dump_config(cfg))
370
+ monkeypatch.setattr(cli, "load_config", lambda: load_config(cfg.path))
371
+ (home / ".claude" / "settings.json").write_text(json.dumps({"model": "claude-sonnet-5-5"}))
372
+ cli.main(["doctor"])
373
+ out = capsys.readouterr().out
374
+ assert "default model: Sonnet 5.5 (claude-sonnet-5-5)" in out
375
+ assert "summaries are written with: opus" in out
376
+ (home / ".claude" / "settings.json").write_text("{}")
377
+ cli.main(["doctor"])
378
+ assert "default model: not set: the plan default" in capsys.readouterr().out
379
+
380
+
381
+ def test_login_panel_only_asks_for_a_name_when_logins_share_a_folder(home, repo):
382
+ cfg = make_cfg(home, repo)
383
+ c = '<input type="hidden" name="csrf" value="t">'
384
+ identity.record_session("s1", home / ".claude", identity.Identity(UID_A, EMAIL_A), "start")
385
+ one = web._detected_section(cfg, c)
386
+ assert "Counted as personal" in one and "/accounts/name" not in one # its own folder: nothing to name
387
+ identity.record_session("s2", home / ".claude", identity.Identity(UID_B, EMAIL_B), "start")
388
+ two = web._detected_section(cfg, c)
389
+ assert two.count('action="/accounts/name"') == 2 and "No name yet" in two # /login switching: name them
@@ -68,8 +68,9 @@ D = date(2026, 9, 20)
68
68
  def home(tmp_path, monkeypatch):
69
69
  h = tmp_path / "home"
70
70
  (h / ".claude" / "projects").mkdir(parents=True)
71
- for var, sub in (("HOME", ""), ("XDG_CONFIG_HOME", ".config"), ("XDG_STATE_HOME", ".local/state"),
72
- ("XDG_DATA_HOME", ".local/share")):
71
+ for var, sub in (("HOME", ""), ("USERPROFILE", ""), ("XDG_CONFIG_HOME", ".config"),
72
+ ("XDG_STATE_HOME", ".local/state"), ("XDG_DATA_HOME", ".local/share"),
73
+ ("APPDATA", "AppData/Roaming"), ("LOCALAPPDATA", "AppData/Local")):
73
74
  monkeypatch.setenv(var, str(h / sub) if sub else str(h))
74
75
  state = h / ".local/state/claude-worklog"
75
76
  for mod in (identity, store, cli, chats):
@@ -85,7 +86,7 @@ def cfg(home):
85
86
  subprocess.run(["git", "init", "-q", str(repo)], check=True)
86
87
  p = home / ".config/claude-worklog/config.toml"
87
88
  p.parent.mkdir(parents=True, exist_ok=True)
88
- p.write_text(f'device_name = "dev1"\nstore_repo = "{repo}"\n'
89
+ p.write_text(f'device_name = "dev1"\nstore_repo = {json.dumps(str(repo))}\n'
89
90
  '[[accounts]]\nname = "personal"\nconfig_dir = "~/.claude"\n'
90
91
  '[[accounts]]\nname = "work"\nconfig_dir = "~/.claude-work"\n'
91
92
  '[collect]\nmax_prompt_chars = 40\n[report]\nuse_ai = false\n')
@@ -93,7 +94,7 @@ def cfg(home):
93
94
 
94
95
 
95
96
  def records(cfg) -> dict[str, dict]:
96
- return {str(p.relative_to(cfg.store_repo)): json.loads(p.read_text())
97
+ return {p.relative_to(cfg.store_repo).as_posix(): json.loads(p.read_text(encoding="utf-8"))
97
98
  for p in sorted(cfg.store_repo.rglob("records/**/*.json"))}
98
99
 
99
100
 
@@ -201,7 +202,11 @@ def test_record_redacts_truncates_and_never_holds_claudes_text(cfg):
201
202
 
202
203
 
203
204
  def test_untitled_chat_and_assistant_only_day():
204
- days, _ = chats.split_days(conv("c1", [human("hi", ts(D, 9))], name=""))
205
+ long = "please help me plan the database migration for the orders service " * 3
206
+ days, _ = chats.split_days(conv("c1", [human(long + "\nsecond line", ts(D, 9))], name=""))
207
+ title = chats.build_record(days[D], "personal", load_cfg_stub())["title"]
208
+ assert title.startswith("please help me plan") and title.endswith("…") and len(title) <= 80
209
+ days, _ = chats.split_days(conv("c3", [assistant(ts(D, 9), [tool("Bash", "toolu_01X")])], name=""))
205
210
  assert chats.build_record(days[D], "personal", load_cfg_stub())["title"] == "Untitled chat"
206
211
  days, _ = chats.split_days(conv("c2", [assistant(ts(D, 9))]))
207
212
  assert chats.build_record(days[D], "personal", load_cfg_stub()) is None
@@ -251,7 +256,9 @@ def test_account_choice_is_remembered_and_records_move(cfg, tmp_path):
251
256
  assert res.account == "work" and list(records(cfg)) == [f"records/{D.isoformat()}/dev1/work/chat-c1.json"]
252
257
  assert chats.import_chats(cfg, z).account == "work" # remembered for this claude.ai login
253
258
  state = json.loads(chats.state_path().read_text())
254
- assert ACCT not in json.dumps(state) and oct(chats.state_path().stat().st_mode)[-3:] == "600"
259
+ assert ACCT not in json.dumps(state)
260
+ if os.name != "nt": # Windows has no Unix modes; files there are private to the user profile
261
+ assert oct(chats.state_path().stat().st_mode)[-3:] == "600"
255
262
 
256
263
 
257
264
  def test_cli_import_chats(cfg, tmp_path, capsys, monkeypatch):
@@ -303,7 +310,7 @@ def test_downloads_dir_from_xdg_user_dirs(home):
303
310
  from worklog.config import default_downloads_dir
304
311
  assert default_downloads_dir() == home / "Downloads"
305
312
  (home / ".config").mkdir(exist_ok=True)
306
- (home / ".config/user-dirs.dirs").write_text('XDG_DOWNLOAD_DIR="$HOME/Téléchargements"\n')
313
+ (home / ".config/user-dirs.dirs").write_text('XDG_DOWNLOAD_DIR="$HOME/Téléchargements"\n', encoding="utf-8")
307
314
  assert default_downloads_dir() == home / "Téléchargements"
308
315
 
309
316
 
@@ -405,3 +412,66 @@ def test_sources_page_and_settings(server, cfg, monkeypatch):
405
412
  assert new.chat_account == "work" and new.watch_downloads and new.downloads_dir == Path.home() / "dl"
406
413
  assert app.post("/sources/settings", {"account": "work", "downloads": "/etc"})[1]
407
414
  assert app.post("/sources/settings", {"account": "ghost"})[1]
415
+
416
+
417
+ # ------------------------------------------------------------------ 0.2.1: summaries, findability, per-chat view
418
+ def test_chat_summary_is_opt_in_cleaned_and_redacted(cfg, tmp_path):
419
+ key = "sk-ant-api03-" + "b" * 30
420
+ summary = f"**Conversation overview**\n\nThe person built a *Trello* sync and pasted {key}. " + "More detail. " * 80
421
+ z = write_zip(tmp_path / "c.zip", [dict(conv("c1", [human("sync my sheet", ts(D, 9))]), summary=summary)])
422
+ path = f"records/{D.isoformat()}/dev1/personal/chat-c1.json"
423
+ chats.import_chats(cfg, z)
424
+ assert "summary" not in records(cfg)[path] # off by default: Claude's text is not stored
425
+
426
+ cfg.keep_chat_summaries = True
427
+ assert chats.import_chats(cfg, z).written == 1 # re-import adds it
428
+ kept = records(cfg)[path]["summary"]
429
+ assert kept.startswith("The person built a Trello sync") and "**" not in kept and "overview" not in kept
430
+ assert key not in kept and "[REDACTED]" in kept
431
+ assert len(kept) <= chats.MAX_SUMMARY_CHARS and kept.endswith("…")
432
+
433
+ cfg.keep_chat_summaries = False
434
+ assert chats.strip_summaries(cfg) == 1 and "summary" not in records(cfg)[path]
435
+ assert chats.strip_summaries(cfg) == 0
436
+
437
+
438
+ def test_latest_day_and_chat_days(cfg, tmp_path):
439
+ later = D + timedelta(days=3)
440
+ convs = [conv("c1", [human("a", ts(D, 9))]), conv("c2", [human("b", ts(D, 10)), human("c", ts(later, 11))]),
441
+ conv("c3", [human("d", ts(later, 12))])]
442
+ res = chats.import_chats(cfg, write_zip(tmp_path / "c.zip", convs))
443
+ assert res.latest_day == later and f"Latest chat: {later:%a %d %b %Y}" in res.message()
444
+ assert chats.chat_days(cfg.store_repo) == [(later, 2), (D, 2)]
445
+ assert chats.chat_days(cfg.store_repo, limit=1) == [(later, 2)]
446
+
447
+
448
+ def test_day_page_lists_each_chat_and_marks_chat_days(cfg, tmp_path):
449
+ convs = [conv("c1", [human("first ask", ts(D, 9)), human("second ask", ts(D, 9, 5))], name="Trello sync"),
450
+ conv("c2", [human("price question", ts(D, 15))], name="Keel pricing")]
451
+ chats.import_chats(cfg, write_zip(tmp_path / "c.zip", convs))
452
+ body, _ = web.page_day(web.App(8765), cfg, D)
453
+ assert body.index("Trello sync") < body.index("Keel pricing") # in time order, each with its own messages
454
+ assert "first ask" in body and "second ask" in body and "price question" in body
455
+ assert "claude.ai chats</span>" in body and "2 claude.ai chats" in body and "<circle" in body
456
+ later, _ = web.page_day(web.App(8765), cfg, D + timedelta(days=2))
457
+ assert f'href="/?d={D.isoformat()}"' in later and "latest claude.ai chats" in later
458
+
459
+ accounts, projects = aggregate(list(records(cfg).values()))
460
+ text = render(D, cfg, accounts, projects, {})
461
+ assert "- Trello sync: first ask" in text and "- Keel pricing: price question" in text
462
+
463
+
464
+ def test_turning_summaries_off_in_the_ui_removes_them(server, cfg, monkeypatch):
465
+ app, _ = server
466
+ cfg.keep_chat_summaries = True
467
+ monkeypatch.setattr(web, "save_config", lambda c: None)
468
+ removed = []
469
+ monkeypatch.setattr(chats, "strip_summaries", lambda c: removed.append(c) or 3)
470
+ loc, err, _ = app.post("/sources/settings", {"account": "personal"})
471
+ assert err is None and loc == "/sources?ok=chat_saved" and not cfg.keep_chat_summaries
472
+ for _ in range(100):
473
+ if app.jobs.snapshot()["finished"]:
474
+ break
475
+ time.sleep(0.05)
476
+ assert removed and "Removed claude.ai summaries from 3" in app.jobs.snapshot()["message"]
477
+ assert "Days with chats" not in web.page_sources(app, cfg) # nothing imported yet