worklog-for-claude-code 0.3.0__tar.gz → 0.3.1__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 (43) hide show
  1. {worklog_for_claude_code-0.3.0/worklog_for_claude_code.egg-info → worklog_for_claude_code-0.3.1}/PKG-INFO +2 -2
  2. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/pyproject.toml +2 -2
  3. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_chats.py +2 -2
  4. worklog_for_claude_code-0.3.1/tests/test_forget.py +170 -0
  5. worklog_for_claude_code-0.3.1/tests/test_syncstatus.py +116 -0
  6. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/__init__.py +1 -1
  7. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/collector.py +4 -1
  8. worklog_for_claude_code-0.3.1/worklog/forget.py +211 -0
  9. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/identity.py +10 -1
  10. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/store.py +5 -0
  11. worklog_for_claude_code-0.3.1/worklog/syncstatus.py +148 -0
  12. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/web.py +162 -16
  13. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1/worklog_for_claude_code.egg-info}/PKG-INFO +2 -2
  14. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog_for_claude_code.egg-info/SOURCES.txt +4 -0
  15. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog_for_claude_code.egg-info/requires.txt +1 -1
  16. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/LICENSE +0 -0
  17. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/README.md +0 -0
  18. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/setup.cfg +0 -0
  19. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_accounts.py +0 -0
  20. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_checks.py +0 -0
  21. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_demo.py +0 -0
  22. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_platforms.py +0 -0
  23. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_updates.py +0 -0
  24. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_viz.py +0 -0
  25. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_watcher.py +0 -0
  26. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_worklog.py +0 -0
  27. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/chats.py +0 -0
  28. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/checks.py +0 -0
  29. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/cli.py +0 -0
  30. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/config.py +0 -0
  31. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/demo.py +0 -0
  32. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/hooks.py +0 -0
  33. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/locks.py +0 -0
  34. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/parser.py +0 -0
  35. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/redact.py +0 -0
  36. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/report.py +0 -0
  37. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/services.py +0 -0
  38. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/updates.py +0 -0
  39. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/viz.py +0 -0
  40. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/watcher.py +0 -0
  41. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog_for_claude_code.egg-info/dependency_links.txt +0 -0
  42. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog_for_claude_code.egg-info/entry_points.txt +0 -0
  43. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog_for_claude_code.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: worklog-for-claude-code
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
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
5
  Author: Urvin
6
6
  License-Expression: MIT
@@ -33,7 +33,7 @@ Provides-Extra: test
33
33
  Requires-Dist: pytest>=7; extra == "test"
34
34
  Provides-Extra: dev
35
35
  Requires-Dist: pytest>=7; extra == "dev"
36
- Requires-Dist: ruff==0.17.0; extra == "dev"
36
+ Requires-Dist: ruff==0.17.3; extra == "dev"
37
37
  Requires-Dist: build; extra == "dev"
38
38
  Dynamic: license-file
39
39
 
@@ -5,7 +5,7 @@ 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.3.0"
8
+ version = "0.3.1"
9
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"
@@ -35,7 +35,7 @@ dependencies = ['tomli>=2.0; python_version < "3.11"']
35
35
 
36
36
  [project.optional-dependencies]
37
37
  test = ["pytest>=7"]
38
- dev = ["pytest>=7", "ruff==0.17.0", "build"]
38
+ dev = ["pytest>=7", "ruff==0.17.3", "build"]
39
39
 
40
40
  [project.urls]
41
41
  Homepage = "https://github.com/us-urvin/worklog-for-claude-code"
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
 
14
14
  import pytest
15
15
 
16
- from worklog import chats, cli, identity, store, web
16
+ from worklog import chats, cli, identity, store, syncstatus, web
17
17
  from worklog.config import ConfigError, dump_config, load_config
18
18
  from worklog.report import aggregate, render
19
19
 
@@ -73,7 +73,7 @@ def home(tmp_path, monkeypatch):
73
73
  ("APPDATA", "AppData/Roaming"), ("LOCALAPPDATA", "AppData/Local")):
74
74
  monkeypatch.setenv(var, str(h / sub) if sub else str(h))
75
75
  state = h / ".local/state/claude-worklog"
76
- for mod in (identity, store, cli, chats):
76
+ for mod in (identity, store, cli, chats, syncstatus):
77
77
  monkeypatch.setattr(mod, "STATE_DIR", state)
78
78
  assert Path.home() == h
79
79
  return h
@@ -0,0 +1,170 @@
1
+ """Forget a login: deletes exactly its Worklog history on this device, after a confirmation."""
2
+ import json
3
+ from datetime import datetime, timedelta
4
+
5
+ import pytest
6
+ from test_accounts import EMAIL_A, EMAIL_B, UID_A, UID_B, login, make_cfg, records, start_session, transcript
7
+
8
+ from worklog import collector, forget, identity, watcher, web
9
+ from worklog.config import load_config, save_config
10
+
11
+ UID_C, EMAIL_C = "33333333-cccc-4ccc-8ccc-333333333333", "stranger@viitor.example"
12
+
13
+
14
+ @pytest.fixture
15
+ def isolated(home, monkeypatch):
16
+ wpath = home / ".local/state/claude-worklog/watch.json"
17
+ monkeypatch.setattr(watcher, "state_path", lambda: wpath)
18
+ monkeypatch.setattr(collector, "watch_state", lambda: watcher.load_state(wpath))
19
+ return home
20
+
21
+
22
+ def setup_user_case(home, repo):
23
+ """Like the reported setup: three logins in ~/.claude, two named, one ("stranger") without a name."""
24
+ cfg = make_cfg(home, repo, f'[[accounts]]\nname = "laracopilot"\nconfig_dir = "~/.claude"\n'
25
+ f'match_account_id = "{UID_B}"\n')
26
+ cfg.accounts[0] = cfg.accounts[0].__class__("personal", cfg.accounts[0].config_dir, match_account_id=UID_A)
27
+ cfg.accounts.reverse() # laracopilot first: it is the folder default for logins without a name
28
+ save_config(cfg, cfg.path)
29
+ cfg = load_config(cfg.path)
30
+ now = datetime.now().astimezone().replace(microsecond=0)
31
+ for uid, email, sid, days_ago in ((UID_A, EMAIL_A, "s-personal", 0), (UID_B, EMAIL_B, "s-lara", 0),
32
+ (UID_C, EMAIL_C, "s-stranger-old", 6), (UID_C, EMAIL_C, "s-stranger", 0)):
33
+ login(home, uid, email)
34
+ start_session(home, sid)
35
+ transcript(home, sid, now - timedelta(days=days_ago), home)
36
+ collector.collect(cfg, [now.date() - timedelta(days=6), now.date()])
37
+ return cfg, now
38
+
39
+
40
+ def paths(repo):
41
+ return sorted(records(repo))
42
+
43
+
44
+ def test_forget_an_unnamed_login_deletes_only_its_sessions(isolated, repo):
45
+ cfg, now = setup_user_case(isolated, repo)
46
+ before = paths(repo)
47
+ assert len(before) == 4 and sum("s-stranger" in p for p in before) == 2
48
+ p = forget.plan(cfg, identity.ref(UID_C))
49
+ assert p.sessions == {"s-stranger", "s-stranger-old"} and len(p.files) == 2 and len(p.days) == 2
50
+ assert p.account is None # it was never named: no account goes away
51
+
52
+ msg = forget.apply(load_config(cfg.path), p)
53
+ assert "deleted 2 session record(s) from 2 day(s)" in msg
54
+ after = paths(repo)
55
+ assert not any("s-stranger" in x for x in after) and len(after) == 2
56
+ assert {x.split("/")[3] for x in after} == {"personal", "laracopilot"}
57
+ assert UID_C not in {x["account_id"] for x in identity.detected_logins()}
58
+
59
+ # a later sync never writes the forgotten sessions again
60
+ collector.collect(load_config(cfg.path), [now.date() - timedelta(days=6), now.date()])
61
+ assert not any("s-stranger" in x for x in paths(repo))
62
+
63
+
64
+ def test_forget_a_named_login_removes_its_account_and_rehomes_the_rest(isolated, repo):
65
+ cfg, now = setup_user_case(isolated, repo)
66
+ cfg.summarizer_account = "laracopilot"
67
+ save_config(cfg, cfg.path)
68
+ p = forget.plan(load_config(cfg.path), identity.ref(UID_B))
69
+ assert p.account == "laracopilot" and p.sessions == {"s-lara"}
70
+ msg = forget.apply(load_config(cfg.path), p)
71
+ new = load_config(cfg.path)
72
+ assert new.account("laracopilot") is None and new.summarizer_account == "personal"
73
+ assert "removed account laracopilot" in msg and "moved 2 other record(s)" in msg
74
+ after = paths(repo)
75
+ assert not any("s-lara" in x for x in after)
76
+ # the stranger's sessions had fallen back to laracopilot: now with the folder's new default, not orphaned
77
+ assert all("/laracopilot/" not in x for x in after)
78
+ assert sum("/personal/s-stranger" in x for x in after) == 2
79
+ assert records(repo)[next(x for x in after if "s-stranger" in x)]["account"] == "personal"
80
+
81
+
82
+ def test_forget_the_only_account_keeps_it_but_unlinks(isolated, repo):
83
+ cfg = make_cfg(isolated, repo, "")
84
+ cfg.accounts[0] = cfg.accounts[0].__class__("solo", cfg.accounts[0].config_dir, match_account_id=UID_A)
85
+ cfg.summarizer_account = "solo"
86
+ save_config(cfg, cfg.path)
87
+ cfg = load_config(cfg.path)
88
+ login(isolated, UID_A, EMAIL_A)
89
+ start_session(isolated, "s1")
90
+ transcript(isolated, "s1", datetime.now().astimezone(), isolated)
91
+ collector.collect(cfg, [datetime.now().date()])
92
+ p = forget.plan(cfg, identity.ref(UID_A))
93
+ assert p.keep_account
94
+ forget.apply(cfg, p)
95
+ new = load_config(cfg.path)
96
+ assert [a.name for a in new.accounts] == ["solo"] and new.accounts[0].match_account_id is None
97
+ assert paths(repo) == []
98
+
99
+
100
+ def test_forget_finds_sessions_the_watcher_matched(isolated, repo):
101
+ cfg = make_cfg(isolated, repo,
102
+ f'[[accounts]]\nname = "work"\nconfig_dir = "~/.claude"\nmatch_account_id = "{UID_B}"\n')
103
+ now = datetime.now().astimezone().replace(microsecond=0)
104
+ w = watcher.Watcher(watcher.state_path())
105
+ t = now - timedelta(minutes=20)
106
+ login(isolated, UID_B, EMAIL_B) # switched by some tool; no hook anywhere
107
+ while t <= now:
108
+ w._last_discover = float("inf")
109
+ w.step(now=t)
110
+ t += timedelta(seconds=30)
111
+ transcript(isolated, "s-w", now - timedelta(minutes=10), isolated)
112
+ collector.collect(cfg, [now.date()])
113
+ rec = records(repo)[f"records/{now.date().isoformat()}/dev1/work/s-w.json"]
114
+ assert rec["account_source"] == "watcher"
115
+ p = forget.plan(cfg, identity.ref(UID_B))
116
+ assert p.sessions == {"s-w"} and p.account == "work"
117
+
118
+
119
+ def test_forget_drops_the_saved_summaries_of_those_projects(isolated, repo):
120
+ cfg, now = setup_user_case(isolated, repo)
121
+ d = now.date().isoformat()
122
+ rep = repo / "reports" / d
123
+ rep.mkdir(parents=True)
124
+ (rep / "dev1.json").write_text(json.dumps({"summaries": {"misc:R&D / misc": {"bullets": ["- secret work"]},
125
+ "other": {"bullets": ["- keep me"]}}}))
126
+ (rep / "dev1.md").write_text("- secret work\n")
127
+ forget.apply(load_config(cfg.path), forget.plan(cfg, identity.ref(UID_C)))
128
+ meta = json.loads((rep / "dev1.json").read_text())
129
+ assert "misc:R&D / misc" not in meta["summaries"] and "other" in meta["summaries"]
130
+ assert not (rep / "dev1.md").exists()
131
+
132
+
133
+ def test_the_same_account_logging_in_again_is_tracked_again(isolated, repo):
134
+ cfg, now = setup_user_case(isolated, repo)
135
+ forget.apply(load_config(cfg.path), forget.plan(cfg, identity.ref(UID_C)))
136
+ login(isolated, UID_C, EMAIL_C)
137
+ start_session(isolated, "s-stranger-new")
138
+ transcript(isolated, "s-stranger-new", now, isolated)
139
+ collector.collect(load_config(cfg.path), [now.date()])
140
+ assert any("s-stranger-new" in x for x in paths(repo))
141
+ assert UID_C in {x["account_id"] for x in identity.detected_logins()}
142
+
143
+
144
+ def test_ui_asks_first_and_explains_what_is_lost(isolated, repo, monkeypatch):
145
+ cfg, _ = setup_user_case(isolated, repo)
146
+ monkeypatch.setattr(web, "_try_config", lambda: load_config(cfg.path))
147
+ app = web.App(8765)
148
+ ref = identity.ref(UID_C)
149
+ settings = web.page_settings(app, cfg)
150
+ assert f'href="/settings?forget={ref}#forget"' in settings and "Forget…" in settings
151
+
152
+ card = web.page_settings(app, cfg, ref)
153
+ assert "Forget s***@viitor.example?" in card and "2 Claude Code session records" in card
154
+ assert "can&#39;t be undone" in card and "Claude Code&#39;s own files" in card and 'required' in card
155
+ assert EMAIL_C not in card and UID_C not in card # masked, as everywhere
156
+
157
+ n = len(paths(repo))
158
+ loc, err, _ = app.post("/accounts/forget", {"ref": ref}) # the box isn't ticked: nothing happens
159
+ assert err and len(paths(repo)) == n
160
+ loc, err, _ = app.post("/accounts/forget", {"ref": "000000000000", "confirm": "1"})
161
+ assert err and len(paths(repo)) == n
162
+ loc, err, _ = app.post("/accounts/forget", {"ref": ref, "confirm": "1"})
163
+ assert err is None and loc.startswith("/settings?ok=job")
164
+ import time
165
+ for _ in range(200):
166
+ if not app.jobs.snapshot()["running"]:
167
+ break
168
+ time.sleep(0.02)
169
+ assert app.jobs.snapshot()["ok"], app.jobs.snapshot()["message"]
170
+ assert not any("s-stranger" in x for x in paths(repo))
@@ -0,0 +1,116 @@
1
+ """Settings → Private data repo: connected or not, which repo, private or public, and whether syncing works."""
2
+ import io
3
+ import json
4
+ import subprocess
5
+ from urllib.error import HTTPError
6
+
7
+ import pytest
8
+ from test_accounts import make_cfg
9
+
10
+ from worklog import syncstatus, web
11
+ from worklog.store import GitStore
12
+
13
+
14
+ def git(repo, *args):
15
+ subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
16
+
17
+
18
+ def section(cfg):
19
+ return web.data_repo_section(web.App(1), cfg)
20
+
21
+
22
+ @pytest.fixture
23
+ def no_network(monkeypatch):
24
+ def fail(*a, **k):
25
+ raise AssertionError("no network expected here")
26
+ monkeypatch.setattr(syncstatus, "urlopen", fail)
27
+
28
+
29
+ def test_not_a_git_folder_says_not_set_up(home, repo, no_network):
30
+ cfg = make_cfg(home, repo)
31
+ cfg.store_repo = home / "nowhere"
32
+ html = section(cfg)
33
+ assert "Not set up" in html and "git init" in html
34
+
35
+
36
+ def test_local_only_explains_how_to_connect(home, repo, no_network):
37
+ html = section(make_cfg(home, repo))
38
+ assert "Local only" in html and "Not connected" in html and "remote add origin" in html and "Copy" in html
39
+
40
+
41
+ def test_connected_shows_which_repo_and_never_the_credentials(home, repo, monkeypatch):
42
+ git(repo, "remote", "add", "origin", "https://me:ghp_SECRET123@github.com/me/claude-worklog-data.git")
43
+ monkeypatch.setattr(syncstatus, "urlopen", lambda *a, **k: (_ for _ in ()).throw(
44
+ HTTPError("u", 404, "Not Found", {}, io.BytesIO(b""))))
45
+ syncstatus.visibility("github.com", "me/claude-worklog-data") # what the background check does
46
+ html = section(make_cfg(home, repo))
47
+ assert "Connected" in html and "github.com/me/claude-worklog-data" in html
48
+ assert 'href="https://github.com/me/claude-worklog-data"' in html
49
+ assert "SECRET" not in html and "ghp_" not in html and "me:" not in html
50
+ assert "🔒 Private" in html and "Not uploaded yet" in html
51
+
52
+
53
+ def test_public_repo_is_flagged_everywhere(home, repo, monkeypatch):
54
+ git(repo, "remote", "add", "origin", "git@github.com:me/claude-worklog-data.git")
55
+ monkeypatch.setattr(syncstatus, "urlopen", lambda *a, **k: io.BytesIO(json.dumps({"private": False}).encode()))
56
+ assert syncstatus.visibility("github.com", "me/claude-worklog-data") == "public"
57
+ cfg = make_cfg(home, repo)
58
+ assert "PUBLIC" in section(cfg) and "Change visibility" in section(cfg)
59
+ assert "is public" in web._public_repo_warning(cfg) # the warning on every page
60
+
61
+
62
+ def test_visibility_is_cached_and_only_asked_for_github(home, monkeypatch):
63
+ calls = []
64
+
65
+ def fake(req, timeout):
66
+ calls.append(req.full_url)
67
+ return io.BytesIO(b'{"private": true}')
68
+ monkeypatch.setattr(syncstatus, "urlopen", fake)
69
+ assert syncstatus.visibility("github.com", "me/r") == "private"
70
+ assert syncstatus.visibility("github.com", "me/r") == "private" and len(calls) == 1 # cached
71
+ assert syncstatus.visibility("gitlab.com", "me/r") == "unknown" and len(calls) == 1 # never asks other hosts
72
+ assert calls == ["https://api.github.com/repos/me/r"]
73
+ monkeypatch.setattr(syncstatus, "urlopen", lambda *a, **k: (_ for _ in ()).throw(OSError("offline")))
74
+ assert syncstatus.visibility("github.com", "me/other") == "unknown"
75
+
76
+
77
+ def test_store_records_uploads_and_failures(home, repo, tmp_path, no_network):
78
+ bare = tmp_path / "remote.git"
79
+ subprocess.run(["git", "init", "-q", "--bare", str(bare)], check=True)
80
+ git(repo, "remote", "add", "origin", str(bare))
81
+ store = GitStore(repo)
82
+ store.write_json("records/2026-11-01/dev/personal/a.json", {"a": 1})
83
+ store.commit_and_push("test")
84
+ assert syncstatus.last("push")["ok"] is True
85
+ git(repo, "remote", "set-url", "origin", str(tmp_path / "missing.git"))
86
+ store.write_json("records/2026-11-01/dev/personal/b.json", {"b": 1})
87
+ store.commit_and_push("test 2")
88
+ failed = syncstatus.last("push")
89
+ assert failed["ok"] is False and failed["error"]
90
+
91
+
92
+ def test_failed_upload_and_waiting_changes_are_shown(home, repo, no_network):
93
+ git(repo, "remote", "add", "origin", "git@gitlab.com:me/data.git")
94
+ GitStore(repo).write_json("records/2026-11-01/dev/personal/a.json", {"a": 1})
95
+ git(repo, "add", "-A")
96
+ subprocess.run(["git", "-C", str(repo), "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"],
97
+ check=True)
98
+ syncstatus.record("push", False, "fatal: Could not read from remote repository.")
99
+ html = section(make_cfg(home, repo))
100
+ assert "Last upload failed" in html and "Could not read from remote repository" in html
101
+ assert "1 change waiting to upload" in html
102
+ assert "Privacy not checked" in html # not GitHub: no request is made
103
+ syncstatus.record("push", True)
104
+ assert "Synced" in section(make_cfg(home, repo))
105
+
106
+
107
+ def test_test_connection_works_and_explains_failures(home, repo, tmp_path):
108
+ bare = tmp_path / "remote.git"
109
+ subprocess.run(["git", "init", "-q", "--bare", str(bare)], check=True)
110
+ git(repo, "remote", "add", "origin", str(bare))
111
+ cfg = make_cfg(home, repo)
112
+ ok, msg = syncstatus.test_connection(cfg)
113
+ assert ok and "Connection works" in msg
114
+ git(repo, "remote", "set-url", "origin", str(tmp_path / "missing.git"))
115
+ ok, msg = syncstatus.test_connection(cfg)
116
+ assert not ok and msg.startswith("Can't reach your data repo")
@@ -1,3 +1,3 @@
1
1
  """claude-worklog: daily work log + token report for Claude Code."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from urllib.parse import urlsplit
10
10
 
11
11
  from .config import SUMMARIZER_CWD, Account, Config, home_relative
12
- from .identity import Identity, load_state, session_logins
12
+ from .identity import Identity, forgotten_sessions, load_state, session_logins
13
13
  from .parser import ParseStats, SessionDay, parse_transcript
14
14
  from .redact import redact
15
15
  from .store import GitStore
@@ -181,6 +181,7 @@ def collect(cfg: Config, days: list[date]) -> int:
181
181
  store = GitStore(cfg.store_repo)
182
182
  logins: dict[str, Identity] = session_logins() # local state written by the SessionStart hook
183
183
  watched = watch_state() # login timeline kept by the watcher (covers sessions without a hook record)
184
+ forgotten = forgotten_sessions() # deleted by "Forget this login": never written again
184
185
  written = 0
185
186
  with store.lock():
186
187
  store.pull()
@@ -198,6 +199,8 @@ def collect(cfg: Config, days: list[date]) -> int:
198
199
  except OSError as exc:
199
200
  log.warning("cannot read %s: %s", path, exc)
200
201
  for sd in sessions.values():
202
+ if sd.session_id in forgotten:
203
+ continue
201
204
  login = logins.get(sd.session_id)
202
205
  source = "hook"
203
206
  if login is None:
@@ -0,0 +1,211 @@
1
+ """Forget a login: delete Worklog's records of its Claude Code sessions on this device.
2
+
3
+ Two steps, so the UI can show exactly what will be lost before anything happens:
4
+
5
+ * `plan(cfg, ref)` works out which sessions belong to the login (recorded by the hook, or
6
+ matched by the account watcher), which record files and days that touches, and which
7
+ account goes away with it. It changes nothing.
8
+ * `apply(cfg, plan)` deletes those records and the saved summaries of the projects involved,
9
+ removes the account linked only to that login, forgets the login, and remembers the
10
+ sessions so a later sync never writes them again.
11
+
12
+ Claude Code's own files are never touched. Records synced from other devices aren't removed
13
+ (this device can't tell which of their sessions belong to the login), and the old versions
14
+ stay in the data repo's git history.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ from dataclasses import dataclass, field, replace
21
+ from datetime import date, datetime
22
+ from pathlib import Path
23
+
24
+ from .config import Config, home_relative, save_config
25
+ from .identity import _lock, _save_state, detected_logins, load_state, mask_email, state_path
26
+ from .store import GitStore
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass
32
+ class Plan:
33
+ ref: str
34
+ account_id: str
35
+ email: str | None
36
+ dirs: list[Path]
37
+ sessions: set[str] = field(default_factory=set) # session ids that belong to this login
38
+ files: list[str] = field(default_factory=list) # record paths in the data repo (this device)
39
+ days: set[date] = field(default_factory=set)
40
+ projects: dict[date, set[str]] = field(default_factory=dict) # project keys touched, per day
41
+ account: str | None = None # the account linked only to this login, removed with it
42
+ keep_account: bool = False # it's the last account: keep it, just unlink the login
43
+
44
+ @property
45
+ def label(self) -> str:
46
+ return mask_email(self.email)
47
+
48
+
49
+ def _linked_account(cfg: Config, account_id: str, email: str | None):
50
+ for a in cfg.accounts:
51
+ if a.match_account_id == account_id:
52
+ return a
53
+ if a.match_email and email and a.match_email.casefold() == email.casefold():
54
+ return a
55
+ return None
56
+
57
+
58
+ def plan(cfg: Config, ref_: str) -> Plan | None:
59
+ """What forgetting this login would delete. None if the login isn't known on this device."""
60
+ from . import watcher
61
+ from .collector import _SAFE
62
+ from .report import project_key
63
+
64
+ login = next((x for x in detected_logins() if x["ref"] == ref_), None)
65
+ if login is None:
66
+ return None
67
+ uid = login["account_id"]
68
+ p = Plan(ref_, uid, login["email"], login["dirs"])
69
+
70
+ # 1. Sessions the hook saw starting (or ending) under this login.
71
+ for sid, entry in load_state()["sessions"].items():
72
+ if entry.get("id") == uid or (entry.get("id") is None and entry.get("end_id") == uid):
73
+ p.sessions.add(sid)
74
+
75
+ # 2. Records on this device: hook sessions by id, watcher-matched ones by the login timeline.
76
+ watched = watcher.load_state()
77
+ folders = {a.name: a.config_dir for a in cfg.accounts}
78
+ base = cfg.store_repo / "records"
79
+ safe_ids = {_SAFE.sub("_", s): s for s in p.sessions}
80
+ for f in sorted(base.glob(f"*/{cfg.device}/*/*.json")) if base.is_dir() else []:
81
+ try:
82
+ rec = json.loads(f.read_text(encoding="utf-8"))
83
+ except (OSError, ValueError):
84
+ continue
85
+ if rec.get("source") == "claude.ai":
86
+ continue # imported chats belong to a claude.ai login, not to a Claude Code one
87
+ sid = str(rec.get("session_id") or "")
88
+ mine = f.stem in safe_ids or sid in p.sessions
89
+ if not mine and rec.get("account_source") == "watcher":
90
+ folder = folders.get(str(rec.get("account")))
91
+ try:
92
+ start = datetime.fromisoformat(str(rec.get("start")))
93
+ except ValueError:
94
+ start = None
95
+ if folder is not None and start is not None and folder in p.dirs:
96
+ m = watcher.login_at(watched, folder, start)
97
+ mine = m.status == "match" and m.account_id == uid
98
+ if not mine:
99
+ continue
100
+ p.sessions.add(sid)
101
+ rel = f.relative_to(cfg.store_repo).as_posix()
102
+ p.files.append(rel)
103
+ try:
104
+ d = date.fromisoformat(f.parts[-4])
105
+ except ValueError:
106
+ continue
107
+ p.days.add(d)
108
+ p.projects.setdefault(d, set()).add(project_key(rec.get("project") or {}))
109
+
110
+ linked = _linked_account(cfg, uid, login["email"])
111
+ if linked is not None:
112
+ p.account = linked.name
113
+ p.keep_account = len(cfg.accounts) == 1
114
+ return p
115
+
116
+
117
+ def apply(cfg: Config, p: Plan, now: datetime | None = None) -> str:
118
+ """Do it. Returns a message for the UI."""
119
+ from . import watcher
120
+ now = now or datetime.now().astimezone()
121
+ stamp = now.isoformat(timespec="seconds")
122
+ store = GitStore(cfg.store_repo)
123
+ with store.lock():
124
+ store.pull()
125
+ # 1. The account linked only to this login. Its remaining records (other logins that fell back
126
+ # to it as the folder default) move to the folder's new default, so nothing is left orphaned.
127
+ moved = 0
128
+ if p.account and not p.keep_account:
129
+ gone = cfg.account(p.account)
130
+ cfg.accounts = [a for a in cfg.accounts if a.name != p.account]
131
+ if cfg.summarizer_account == p.account:
132
+ cfg.summarizer_account = cfg.accounts[0].name
133
+ if cfg.chat_account == p.account:
134
+ cfg.chat_account = None
135
+ save_config(cfg, cfg.path)
136
+ doomed = set(p.files)
137
+ for f in sorted((cfg.store_repo / "records").glob(f"*/{cfg.device}/{p.account}/*.json")):
138
+ rel = f.relative_to(cfg.store_repo).as_posix()
139
+ if rel in doomed:
140
+ continue
141
+ try:
142
+ rec = json.loads(f.read_text(encoding="utf-8"))
143
+ except (OSError, ValueError):
144
+ continue
145
+ folder = gone.config_dir if gone else None
146
+ new = (cfg.default_account(folder) if folder in cfg.accounts_by_dir() else cfg.accounts[0]).name
147
+ rec["account"] = new
148
+ store.write_json(f"records/{f.parts[-4]}/{cfg.device}/{new}/{f.name}", rec)
149
+ f.unlink()
150
+ moved += 1
151
+ elif p.account and p.keep_account:
152
+ cfg.accounts = [replace(a, match_account_id=None, match_email=None) if a.name == p.account else a
153
+ for a in cfg.accounts]
154
+ save_config(cfg, cfg.path)
155
+
156
+ # 2. The session records themselves.
157
+ deleted = 0
158
+ for rel in p.files:
159
+ f = cfg.store_repo / rel
160
+ if f.exists():
161
+ f.unlink()
162
+ deleted += 1
163
+ try:
164
+ f.parent.rmdir() # drop the account folder if it is now empty
165
+ except OSError:
166
+ pass
167
+
168
+ # 3. Saved summaries of the projects those sessions were in: written again on demand.
169
+ for d, keys in p.projects.items():
170
+ meta_file = cfg.store_repo / "reports" / d.isoformat() / f"{cfg.device}.json"
171
+ try:
172
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
173
+ except (OSError, ValueError):
174
+ continue
175
+ summaries = meta.get("summaries") or {}
176
+ for k in keys:
177
+ summaries.pop(k, None)
178
+ meta["summaries"] = summaries
179
+ store.write_json(meta_file.relative_to(cfg.store_repo).as_posix(), meta)
180
+ md = cfg.store_repo / "reports" / d.isoformat() / f"{cfg.device}.md"
181
+ if md.exists():
182
+ md.unlink() # the Markdown report mentions them too; it is rebuilt with the next summaries
183
+
184
+ store.commit_and_push(f"forget a login on {cfg.device}: {deleted} session record(s)")
185
+
186
+ # 4. Forget the login, remember its sessions as forgotten, and blank it out of the watcher's timeline.
187
+ path = state_path()
188
+ with _lock(path, 5.0):
189
+ state = load_state(path)
190
+ state["logins"].pop(p.account_id, None)
191
+ state["sessions"] = {k: v for k, v in state["sessions"].items()
192
+ if v.get("id") != p.account_id and v.get("end_id") != p.account_id}
193
+ for sid in p.sessions:
194
+ state["forgotten"][sid] = stamp
195
+ _save_state(state, path)
196
+ with _lock(watcher.state_path(), 5.0):
197
+ ws = watcher.load_state()
198
+ for entries in ws["timeline"].values():
199
+ for e in entries:
200
+ if e.get("id") == p.account_id:
201
+ e["id"] = None
202
+ ws["dismissed"] = [k for k in ws["dismissed"] if p.ref not in k]
203
+ _save_state(ws, watcher.state_path())
204
+
205
+ where = ", ".join(home_relative(d) for d in p.dirs) or "this device"
206
+ parts = [f"Forgot {p.label} ({where}): deleted {deleted} session record(s) from {len(p.days)} day(s)"]
207
+ if p.account and not p.keep_account:
208
+ parts.append(f"removed account {p.account}" + (f", moved {moved} other record(s) to the folder default"
209
+ if moved else ""))
210
+ log.info("forgot login %s: %d records", p.ref, deleted)
211
+ return "; ".join(parts) + "."
@@ -104,7 +104,8 @@ def state_path() -> Path:
104
104
 
105
105
 
106
106
  def _empty() -> dict:
107
- return {"version": STATE_VERSION, "sessions": {}, "logins": {}}
107
+ # forgotten: session id -> when it was forgotten. The collector never writes those sessions again.
108
+ return {"version": STATE_VERSION, "sessions": {}, "logins": {}, "forgotten": {}}
108
109
 
109
110
 
110
111
  def load_state(path: Path | None = None) -> dict:
@@ -123,6 +124,8 @@ def load_state(path: Path | None = None) -> dict:
123
124
  for key in ("sessions", "logins"):
124
125
  if isinstance(data.get(key), dict):
125
126
  state[key] = {k: v for k, v in data[key].items() if isinstance(k, str) and isinstance(v, dict)}
127
+ if isinstance(data.get("forgotten"), dict):
128
+ state["forgotten"] = {k: v for k, v in data["forgotten"].items() if isinstance(k, str) and isinstance(v, str)}
126
129
  return state
127
130
 
128
131
 
@@ -146,6 +149,7 @@ def _lock(path: Path, timeout: float):
146
149
  def _prune(state: dict, now: datetime) -> None:
147
150
  cutoff = (now - timedelta(days=KEEP_DAYS)).isoformat()
148
151
  state["sessions"] = {k: v for k, v in state["sessions"].items() if str(v.get("at", "")) >= cutoff}
152
+ state["forgotten"] = {k: v for k, v in state["forgotten"].items() if v >= cutoff}
149
153
 
150
154
 
151
155
  def record_session(session_id: str, config_dir: Path, ident: Identity | None, event: str,
@@ -212,6 +216,11 @@ def session_logins(path: Path | None = None) -> dict[str, Identity]:
212
216
  return out
213
217
 
214
218
 
219
+ def forgotten_sessions(path: Path | None = None) -> set[str]:
220
+ """Sessions the user asked Worklog to forget: never collected again."""
221
+ return set(load_state(path)["forgotten"])
222
+
223
+
215
224
  def detected_logins(path: Path | None = None) -> list[dict]:
216
225
  """Accounts seen logged in, newest first: {ref, account_id, email, dirs, last_seen}."""
217
226
  rows = []
@@ -14,6 +14,7 @@ import time
14
14
  from contextlib import contextmanager
15
15
  from pathlib import Path
16
16
 
17
+ from . import syncstatus
17
18
  from .config import STATE_DIR, ConfigError
18
19
  from .locks import exclusive
19
20
 
@@ -61,10 +62,12 @@ class GitStore:
61
62
  return
62
63
  try:
63
64
  self._git("pull", "--rebase", "--autostash", timeout=90)
65
+ syncstatus.record("pull", True)
64
66
  except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
65
67
  # Offline, empty repo, or first run: work locally, sync next time.
66
68
  detail = getattr(exc, "stderr", "") or str(exc)
67
69
  log.warning("git pull failed (continuing locally): %s", detail.strip()[:300])
70
+ syncstatus.record("pull", False, detail)
68
71
  if (self.path / ".git" / "rebase-merge").exists() or (self.path / ".git" / "rebase-apply").exists():
69
72
  self._git("rebase", "--abort", check=False)
70
73
 
@@ -85,9 +88,11 @@ class GitStore:
85
88
  return
86
89
  try:
87
90
  self._git("push", "-q", "-u", "origin", "HEAD", timeout=90)
91
+ syncstatus.record("push", True)
88
92
  except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
89
93
  detail = getattr(exc, "stderr", "") or str(exc)
90
94
  log.warning("git push failed; commit kept locally and retried next run: %s", detail.strip()[:300])
95
+ syncstatus.record("push", False, detail)
91
96
 
92
97
  # ----------------------------------------------------------------- files
93
98
  def _target(self, rel: str) -> Path:
@@ -0,0 +1,148 @@
1
+ """Is the data repo connected to a remote, which one, is it private, and does syncing work?
2
+
3
+ * The store records the result of every pull and push here (`record`), so Settings can say
4
+ "last upload 5 min ago" or show the last error.
5
+ * For GitHub remotes, `visibility` asks GitHub's public API whether the repo can be seen
6
+ without logging in: a 404 means private (good), "private": false means anyone can read it.
7
+ Only the repo's name is sent, to the host that stores it anyway; the answer is kept 6 hours.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import time
15
+ from pathlib import Path
16
+ from urllib.error import HTTPError
17
+ from urllib.request import Request, urlopen
18
+
19
+ from .config import STATE_DIR, Config
20
+
21
+ VISIBILITY_TTL = 6 * 3600
22
+
23
+
24
+ def _path() -> Path:
25
+ return STATE_DIR / "sync.json"
26
+
27
+
28
+ def _load() -> dict:
29
+ try:
30
+ data = json.loads(_path().read_text(encoding="utf-8"))
31
+ return data if isinstance(data, dict) else {}
32
+ except (OSError, ValueError):
33
+ return {}
34
+
35
+
36
+ def _save(data: dict) -> None:
37
+ try:
38
+ _path().parent.mkdir(parents=True, exist_ok=True)
39
+ _path().write_text(json.dumps(data), encoding="utf-8")
40
+ except OSError:
41
+ pass # status bookkeeping must never break a sync
42
+
43
+
44
+ def record(kind: str, ok: bool, error: str = "") -> None:
45
+ """Remember the outcome of a "pull" or "push" (called by the store)."""
46
+ data = _load()
47
+ data[kind] = {"at": time.time(), "ok": ok, "error": error.strip()[:300]}
48
+ _save(data)
49
+
50
+
51
+ def last(kind: str) -> dict | None:
52
+ v = _load().get(kind)
53
+ return v if isinstance(v, dict) else None
54
+
55
+
56
+ def _git(repo: Path, *args: str, timeout: int = 10) -> tuple[int, str, str]:
57
+ env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
58
+ env.setdefault("GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o ConnectTimeout=15")
59
+ try:
60
+ r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, encoding="utf-8",
61
+ errors="replace", timeout=timeout, env=env)
62
+ except (OSError, subprocess.TimeoutExpired) as exc:
63
+ return 1, "", str(exc)
64
+ return r.returncode, r.stdout.strip(), r.stderr.strip()
65
+
66
+
67
+ def remote(cfg: Config) -> dict | None:
68
+ """{"host", "slug"} of the data repo's origin (credentials dropped), or None. One local git call."""
69
+ from .collector import sanitize_remote
70
+ if not (cfg.store_repo / ".git").exists():
71
+ return None
72
+ _, url, _ = _git(cfg.store_repo, "remote", "get-url", "origin")
73
+ return sanitize_remote(url) if url else None
74
+
75
+
76
+ def status(cfg: Config) -> dict:
77
+ """Everything Settings shows about the data repo. Runs only local git commands."""
78
+ from .collector import sanitize_remote
79
+ repo = cfg.store_repo
80
+ out: dict = {"is_repo": (repo / ".git").exists(), "remote": None, "web_url": None, "branch": "",
81
+ "last_change": "", "waiting": 0, "push": last("push"), "pull": last("pull")}
82
+ if not out["is_repo"]:
83
+ return out
84
+ _, url, _ = _git(repo, "remote", "get-url", "origin")
85
+ r = sanitize_remote(url) if url else None
86
+ if r:
87
+ out["remote"] = f"{r['host']}/{r['slug']}"
88
+ out["host"], out["slug"] = r["host"], r["slug"]
89
+ out["web_url"] = f"https://{r['host']}/{r['slug']}"
90
+ _, out["branch"], _ = _git(repo, "rev-parse", "--abbrev-ref", "HEAD")
91
+ _, out["last_change"], _ = _git(repo, "log", "-1", "--format=%cr")
92
+ if out["remote"]:
93
+ code, n, _ = _git(repo, "rev-list", "--count", "@{u}..HEAD")
94
+ if code != 0: # never pushed (no upstream yet): everything is waiting
95
+ code, n, _ = _git(repo, "rev-list", "--count", "HEAD")
96
+ out["waiting"] = int(n) if code == 0 and n.isdigit() else 0
97
+ return out
98
+
99
+
100
+ def cached_visibility(host: str | None, slug: str | None) -> str | None:
101
+ """The last answer for this repo if it is fresh, "unknown" for non-GitHub hosts, else None (not checked yet)."""
102
+ if host != "github.com" or not slug:
103
+ return "unknown"
104
+ cached = _load().get("visibility") or {}
105
+ if cached.get("slug") == slug and time.time() - float(cached.get("at", 0)) < VISIBILITY_TTL:
106
+ return str(cached.get("value", "unknown"))
107
+ return None
108
+
109
+
110
+ def visibility(host: str | None, slug: str | None, force: bool = False, timeout: float = 5.0) -> str:
111
+ """"private", "public" or "unknown" (not GitHub, offline, or rate-limited). Cached for 6 hours."""
112
+ if host != "github.com" or not slug:
113
+ return "unknown"
114
+ data = _load()
115
+ cached = data.get("visibility") or {}
116
+ if not force and cached.get("slug") == slug and time.time() - float(cached.get("at", 0)) < VISIBILITY_TTL:
117
+ return str(cached.get("value", "unknown"))
118
+ value = "unknown"
119
+ try:
120
+ req = Request(f"https://api.github.com/repos/{slug}",
121
+ headers={"Accept": "application/vnd.github+json", "User-Agent": "claude-worklog"})
122
+ with urlopen(req, timeout=timeout) as resp: # noqa: S310 - fixed https host
123
+ info = json.loads(resp.read(200_000).decode("utf-8"))
124
+ value = "private" if info.get("private") else "public"
125
+ except HTTPError as exc:
126
+ value = "private" if exc.code == 404 else "unknown" # 404: not visible without logging in
127
+ except (OSError, ValueError):
128
+ value = "unknown"
129
+ data["visibility"] = {"slug": slug, "value": value, "at": time.time()}
130
+ _save(data)
131
+ return value
132
+
133
+
134
+ def test_connection(cfg: Config) -> tuple[bool, str]:
135
+ """Can this computer reach the remote without a password prompt? (git ls-remote)"""
136
+ code, _, err = _git(cfg.store_repo, "ls-remote", "--heads", "origin", timeout=30)
137
+ if code == 0:
138
+ return True, "Connection works: this computer can reach your data repo."
139
+ hint = err.splitlines()[-1] if err else "no answer"
140
+ if "Permission denied" in err or "publickey" in err:
141
+ hint += " (the SSH key isn't allowed on this repo: add it as a deploy key with write access)"
142
+ elif "could not read Username" in err or "terminal prompts disabled" in err:
143
+ hint += " (this computer has no saved login for the repo: set up an SSH key, see the Full setup guide)"
144
+ elif "Could not resolve host" in err:
145
+ hint += " (no internet, or the host name is wrong)"
146
+ elif "not found" in err.lower() or "Repository not found" in err:
147
+ hint += " (check the repo name, and that this key or account can see it)"
148
+ return False, f"Can't reach your data repo: {hint[:250]}"
@@ -25,8 +25,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
25
  from pathlib import Path
26
26
  from urllib.parse import parse_qs, unquote, urlsplit
27
27
 
28
- from . import __version__, chats, checks, services, updates, viz, watcher
29
- from .collector import collect, sanitize_remote
28
+ from . import __version__, chats, checks, forget, services, syncstatus, updates, viz, watcher
29
+ from .collector import collect
30
30
  from .config import (
31
31
  _NAME_RE,
32
32
  CONFIG_PATH,
@@ -284,6 +284,11 @@ ol.checks>li>details,ol.checks>li>span{min-width:0}.tips{margin-top:12px}
284
284
  background:var(--glass);border:1px solid var(--rule);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);box-shadow:var(--shadow)}
285
285
  .notice .ico{font-size:1.3rem}.notice .txt{flex:1 1 280px}.notice .acts{display:flex;gap:8px;align-items:center}
286
286
  .notice .acts form{margin:0}.notice.hook,.notice.apikey{border-color:color-mix(in srgb,var(--bad) 55%,var(--rule))}
287
+ .danger-zone{border-color:color-mix(in srgb,var(--bad) 60%,var(--rule))!important;box-shadow:0 0 0 1px color-mix(in srgb,var(--bad) 35%,transparent),var(--shadow)}
288
+ .danger-zone h2{color:var(--bad)}.danger-zone ul{margin:6px 0 12px;padding-left:1.2em}.danger-zone li{margin:4px 0}
289
+ button.danger-solid{background:#C2334B;color:#fff;box-shadow:none}
290
+ #forget,#logins,#checklist,#add,#data-repo{scroll-margin-top:96px}
291
+ #data-repo .meta{display:flex;flex-wrap:wrap;gap:8px;align-items:center}a.btn.forget{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,var(--rule))}
287
292
  .notice.update{border-color:color-mix(in srgb,var(--ok) 55%,var(--rule))}.notice.update code{margin-right:4px}
288
293
  .notice.login,.notice.folder{border-color:color-mix(in srgb,var(--accent) 55%,var(--rule))}
289
294
  /* ---- motion: everything here is switched off by prefers-reduced-motion below */
@@ -891,13 +896,63 @@ def top_panels(app: App, cfg: Config, active: str = "") -> str:
891
896
  f'<a class="btn" href="/settings#checklist">Fix it</a></span></div>' for x in broken)
892
897
  elif active != "settings": # Settings shows the full checklist in its own section
893
898
  out += checklist_html(app, items)
894
- return out + _notices_html(app, cfg)
899
+ return out + _public_repo_warning(cfg) + _notices_html(app, cfg)
900
+
901
+
902
+ def _public_repo_warning(cfg: Config) -> str:
903
+ """Shown on every page while the data repo is public (from the last check, never waits for the network)."""
904
+ r = syncstatus.remote(cfg)
905
+ if not r or syncstatus.cached_visibility(r["host"], r["slug"]) != "public":
906
+ return ""
907
+ return (f'<div class="notice hook" role="alert"><span class="ico" aria-hidden="true">⚠️</span><span class="txt">'
908
+ f'<b>Your data repo {e(r["host"])}/{e(r["slug"])} is public.</b> Anyone can read your prompts and project names. '
909
+ f'Make it private on GitHub.</span><span class="acts"><a class="btn" href="/settings#data-repo">How</a>'
910
+ f'</span></div>')
895
911
 
896
912
 
897
913
  MODEL_CHOICES = (("", "Account default"), ("opus", "Opus (latest)"), ("sonnet", "Sonnet (latest)"),
898
914
  ("haiku", "Haiku (latest)"), ("fable", "Fable (latest)"))
899
915
 
900
916
 
917
+ def _forget_link(login: dict) -> str:
918
+ return (f'<a class="btn quiet forget" href="/settings?forget={e(login["ref"])}#forget" '
919
+ f'title="Delete Worklog\'s history of this login">Forget…</a>')
920
+
921
+
922
+ def _forget_confirm(app: App, cfg: Config, ref_: str) -> str:
923
+ """The "are you sure?" card: exactly what forgetting this login deletes, and what it doesn't."""
924
+ p = forget.plan(cfg, ref_)
925
+ if p is None:
926
+ return '<div class="banner bad" role="alert">That login is no longer recorded on this device.</div>'
927
+ days = sorted(p.days)
928
+ span = (f" from {len(days)} day{'s' * (len(days) != 1)} ({days[0]:%d %b %Y} – {days[-1]:%d %b %Y})"
929
+ if len(days) > 1 else f" from {days[0]:%d %b %Y}" if days else "")
930
+ lost = [f"<li><b>{len(p.files)} Claude Code session record{'s' * (len(p.files) != 1)}</b>{span}: what you worked "
931
+ f"on, prompts, tokens, files and commits</li>"]
932
+ if p.projects:
933
+ lost.append("<li>The saved summaries of the projects in those sessions (write them again with "
934
+ "“Write summaries” on each day)</li>")
935
+ if p.account and not p.keep_account:
936
+ lost.append(f"<li>The account <b>{e(p.account)}</b>. Its other sessions (from logins without a name) move to "
937
+ f"the folder&#39;s default account.</li>")
938
+ elif p.account:
939
+ lost.append(f"<li>The link between <b>{e(p.account)}</b> and this login (it&#39;s your only account, so it stays)</li>")
940
+ lost.append("<li>The login itself, from this list</li>")
941
+ c = f'<input type="hidden" name="csrf" value="{app.csrf}">'
942
+ return f"""<section class="block danger-zone" id="forget" role="alertdialog" aria-labelledby="forget-title">
943
+ <h2 id="forget-title">Forget {e(p.label)}?</h2>
944
+ <p><b>This permanently deletes Worklog&#39;s history of this login on this device.</b> You will lose:</p>
945
+ <ul>{"".join(lost)}</ul>
946
+ <p class="muted">Not touched: Claude Code&#39;s own files, your other accounts, and records synced from your other
947
+ computers. The old versions stay in your data repo&#39;s git history. If this account logs in again later, its new
948
+ sessions are tracked as usual.</p>
949
+ <form method="post" action="/accounts/forget">{c}<input type="hidden" name="ref" value="{e(p.ref)}">
950
+ <div class="check"><input type="checkbox" id="forget_ok" name="confirm" value="1" required>
951
+ <label for="forget_ok">I understand this deletes the history above and can&#39;t be undone</label></div>
952
+ <div class="row"><button class="danger-solid">Forget and delete</button><a class="btn quiet" href="/settings#logins">Cancel</a></div>
953
+ </form></section>"""
954
+
955
+
901
956
  def _detected_section(cfg: Config, csrf_field: str) -> str:
902
957
  """Logins seen by the SessionStart hook. E-mails are masked; forms carry an opaque ref, never the id."""
903
958
  by_dir = cfg.accounts_by_dir()
@@ -918,7 +973,8 @@ def _detected_section(cfg: Config, csrf_field: str) -> str:
918
973
  where = f'<span class="muted">folder <code>{e(home_relative(d))}</code> · last seen {e(seen)}</span>'
919
974
  if len(by_dir[d]) == 1 and per_dir[d] == 1: # the only login in its own folder: nothing to tell apart
920
975
  rows.append(f'<div class="acct"><h3>{e(mask_email(login["email"]))}</h3><div class="meta">'
921
- f'<span class="pill ok">Counted as {e(current.name)}</span>{where}</div></div>')
976
+ f'<span class="pill ok">Counted as {e(current.name)}</span>{where}</div>'
977
+ f'<div class="row" style="margin-top:8px">{_forget_link(login)}</div></div>')
922
978
  continue
923
979
  status = (f'<span class="pill ok">Counted as {e(current.name)}</span>' if linked
924
980
  else f'<span class="pill bad">No name yet</span> <span class="muted">counted as {e(current.name)} '
@@ -933,7 +989,8 @@ def _detected_section(cfg: Config, csrf_field: str) -> str:
933
989
  f'<label for="{list_id}-in" class="muted" style="margin:0">Account name</label>'
934
990
  f'<input type="text" id="{list_id}-in" name="name" list="{list_id}" value="{e(current.name) if linked else ""}" '
935
991
  f'placeholder="work" required pattern="{NAME_PATTERN}" style="width:auto"><datalist id="{list_id}">{names}</datalist>'
936
- f'<button class="{"quiet" if linked else ""}">{"Rename" if linked else "Name this login"}</button></form></div>')
992
+ f'<button class="{"quiet" if linked else ""}">{"Rename" if linked else "Name this login"}</button>'
993
+ f'{_forget_link(login)}</form></div>')
937
994
  body = "".join(rows) or ('<div class="empty">No logins recorded yet. Install the hook, then start a Claude Code '
938
995
  'session. Each account you log in with appears here.</div>')
939
996
  return (f'<h3 id="logins" style="margin-top:22px">Logins seen on this device</h3><p class="help" style="margin-top:6px">Each account '
@@ -943,16 +1000,74 @@ def _detected_section(cfg: Config, csrf_field: str) -> str:
943
1000
  f'device.</p>{body}')
944
1001
 
945
1002
 
946
- def page_settings(app: App, cfg: Config) -> str:
1003
+ def _ago(ts: float | None) -> str:
1004
+ if not ts:
1005
+ return "never"
1006
+ s = max(0, int(time.time() - ts))
1007
+ for size, unit in ((86400, "day"), (3600, "hour"), (60, "minute")):
1008
+ if s >= size:
1009
+ n = s // size
1010
+ return f"{n} {unit}{'s' * (n != 1)} ago"
1011
+ return "just now"
1012
+
1013
+
1014
+ def data_repo_section(app: App, cfg: Config) -> str:
1015
+ """Connected or not, to which repo, private or not, and whether uploads work."""
947
1016
  c = f'<input type="hidden" name="csrf" value="{app.csrf}">'
948
- store = _store(cfg)
949
- if store:
950
- remotes = [sanitize_remote(u) for u in _git_out(cfg.store_repo, "remote", "get-url", "origin").splitlines()]
951
- remote = next((f"{r['host']}/{r['slug']}" for r in remotes if r), "none (local only)")
952
- last = _git_out(cfg.store_repo, "log", "-1", "--format=%cr") or "no commits yet"
953
- store_state = f'<span class="state-ok">Connected</span>. Remote: {e(remote)}. Last change {e(last)}.'
1017
+ st = syncstatus.status(cfg)
1018
+ folder = e(home_relative(cfg.store_repo))
1019
+ if not st["is_repo"]:
1020
+ body = (f'<div class="meta"><span class="pill bad">Not set up</span><span class="muted"><code>{folder}</code> '
1021
+ f'is not a git folder yet.</span></div><p class="help" style="margin-top:10px">Create it with '
1022
+ f'<code>git init {folder}</code>, or clone your private repo there.</p>')
1023
+ return f'<section class="block" id="data-repo"><h2>Private data repo</h2>{body}</section>'
1024
+ buttons = (f'<div class="row"><form method="post" action="/action/collect">{c}<button>Sync now</button></form>'
1025
+ f'<form method="post" action="/sync/test">{c}<button class="quiet">Test connection</button></form></div>')
1026
+ if not st["remote"]:
1027
+ body = (f'<div class="meta"><span class="pill">Local only</span><span class="muted">Not connected. Your log '
1028
+ f'stays in <code>{folder}</code> on this computer and isn&#39;t backed up.</span></div>'
1029
+ f'<p class="help" style="margin-top:10px">Using Claude Code on more than one computer, or want a backup? '
1030
+ f'Create an <b>empty, private</b> repo on GitHub, GitLab or Bitbucket and connect this folder to it:</p>'
1031
+ f'<div class="cmdrow"><pre class="cmd">git -C {folder} remote add origin git@github.com:YOU/claude-worklog-data.git</pre>'
1032
+ f'<button type="button" class="quiet copy" data-copy="git -C {folder} remote add origin '
1033
+ f'git@github.com:YOU/claude-worklog-data.git">Copy</button></div>'
1034
+ f'<p class="help"><a href="https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#full-setup" '
1035
+ f'target="_blank" rel="noopener noreferrer">Step-by-step guide ↗</a> (including a key so the background '
1036
+ f'service can upload without a password)</p>')
1037
+ return f'<section class="block" id="data-repo"><h2>Private data repo</h2>{body}</section>'
1038
+ vis = syncstatus.cached_visibility(st.get("host"), st.get("slug"))
1039
+ if vis is None: # ask GitHub in the background; the page never waits for it
1040
+ threading.Thread(target=syncstatus.visibility, args=(st["host"], st["slug"]), daemon=True).start()
1041
+ privacy = {"private": '<span class="pill ok">🔒 Private</span>',
1042
+ "public": '<span class="pill bad">⚠️ PUBLIC: anyone can read your log</span>',
1043
+ "unknown": '<span class="pill">Privacy not checked (only possible for GitHub)</span>',
1044
+ None: '<span class="pill">Checking privacy…</span>'}[vis]
1045
+ push, pull = st["push"], st["pull"]
1046
+ if push and not push["ok"]:
1047
+ health = (f'<span class="pill bad">Last upload failed</span><span class="muted">{_ago(push["at"])}: '
1048
+ f'{e(push["error"][:160])}</span>')
1049
+ elif pull and not pull["ok"]:
1050
+ health = (f'<span class="pill bad">Last download failed</span><span class="muted">{_ago(pull["at"])}: '
1051
+ f'{e(pull["error"][:160])}</span>')
1052
+ elif push:
1053
+ health = f'<span class="pill ok">Synced</span><span class="muted">last upload {_ago(push["at"])}</span>'
954
1054
  else:
955
- store_state = '<span class="state-bad">Not a git clone.</span> Clone your private data repo into this folder.'
1055
+ health = '<span class="pill">Not uploaded yet</span><span class="muted">the next sync uploads your log</span>'
1056
+ waiting = (f'<span class="muted">· {st["waiting"]} change{"s" * (st["waiting"] != 1)} waiting to upload</span>'
1057
+ if st["waiting"] else "")
1058
+ warn = ('<p class="state-bad" style="margin:10px 0 0"><b>This repo is public.</b> Your prompts, project names and '
1059
+ 'commit messages can be read by anyone. Make it private in the repo&#39;s settings on GitHub '
1060
+ '(Settings → General → Danger Zone → Change visibility).</p>' if vis == "public" else "")
1061
+ return f"""<section class="block" id="data-repo"><h2>Private data repo</h2>
1062
+ <div class="meta"><span class="pill ok">Connected</span><span>to <a href="{e(st["web_url"])}" target="_blank"
1063
+ rel="noopener noreferrer"><b>{e(st["remote"])}</b> ↗</a></span>{privacy}</div>
1064
+ <div class="meta" style="margin-top:8px">{health}{waiting}</div>{warn}
1065
+ <p class="muted" style="font-size:.85rem;margin:10px 0 0">Folder <code>{folder}</code> · branch <code>{e(st["branch"])}</code>
1066
+ · last change {e(st["last_change"] or "none yet")}</p>{buttons}</section>"""
1067
+
1068
+
1069
+ def page_settings(app: App, cfg: Config, forget_ref: str | None = None) -> str:
1070
+ c = f'<input type="hidden" name="csrf" value="{app.csrf}">'
956
1071
 
957
1072
  logins = detected_logins()
958
1073
  by_dir = cfg.accounts_by_dir()
@@ -1001,7 +1116,8 @@ def page_settings(app: App, cfg: Config) -> str:
1001
1116
  running = app.watcher is not None and app.watcher.running
1002
1117
  watch_state = ('<span class="state-ok">Watching now.</span>' if running else
1003
1118
  '<span class="muted">Not watching in this window (it runs in the background service).</span>')
1004
- return f"""<h1><span class="grad">Settings</span><small>Device {e(cfg.device)}</small></h1>
1119
+ confirm = _forget_confirm(app, cfg, forget_ref) if forget_ref else ""
1120
+ return confirm + f"""<h1><span class="grad">Settings</span><small>Device {e(cfg.device)}</small></h1>
1005
1121
  <section class="block" style="margin-top:24px"><h2>Claude accounts</h2>
1006
1122
  <p class="help">Worklog counts every Claude Code session under one of these accounts. Pick the setup that matches how you use Claude Code:</p>
1007
1123
  <div class="setups"><div><b>Each account in its own folder</b>You start one account with plain <code>claude</code> and another with
@@ -1018,6 +1134,8 @@ def page_settings(app: App, cfg: Config) -> str:
1018
1134
 
1019
1135
  {"" if services.demo_mode() else checklist_html(app, checks.cached(cfg, running), "Setup checklist")}
1020
1136
 
1137
+ {data_repo_section(app, cfg)}
1138
+
1021
1139
  <section class="block"><h2>Account switching and extras</h2>
1022
1140
  <p class="help">While Worklog runs, it notices when you switch Claude accounts (with <code>/login</code> or any one-click
1023
1141
  account tool) and when a new Claude folder appears, so sessions are counted under the right account even without a hook.
@@ -1047,7 +1165,8 @@ account tool) and when a new Claude folder appears, so sessions are counted unde
1047
1165
  <label for="device">Device name</label><input type="text" id="device" name="device" value="{e(cfg.device)}" required pattern="{NAME_PATTERN}">
1048
1166
  <p class="muted" style="font-size:.85rem">Renaming files new sessions under the new name. Earlier days keep the old one.</p>
1049
1167
  <label for="store">Data repo folder</label><input type="text" id="store" name="store" value="{e(home_relative(cfg.store_repo))}" required>
1050
- <p style="font-size:.9rem">{store_state}</p><div class="row"><button>Save changes</button></div></form></section>
1168
+ <p class="muted" style="font-size:.85rem">Connection and sync status: see <a href="#data-repo">Private data repo</a>.</p>
1169
+ <div class="row"><button>Save changes</button></div></form></section>
1051
1170
 
1052
1171
  <section class="block"><h2>Background services</h2><p class="help">These keep running after you close the browser and start again when you log in.</p>
1053
1172
  <div class="tablewrap"><table><tr><th>Service</th><th>State</th></tr>{svc_rows}</table></div>
@@ -1112,6 +1231,16 @@ class App:
1112
1231
  return (f"/?d={day.isoformat()}&ok=job" if started else f"/?d={day.isoformat()}"), None, form
1113
1232
  if path == "/accounts/add":
1114
1233
  return self._account_add(cfg, form)
1234
+ if path == "/sync/test":
1235
+ def job():
1236
+ ok, msg = syncstatus.test_connection(cfg)
1237
+ if not ok:
1238
+ raise RuntimeError(msg)
1239
+ return msg
1240
+ started = self.jobs.start("Testing the connection", job)
1241
+ return ("/settings?ok=job#data-repo" if started else "/settings#data-repo"), None, form
1242
+ if path == "/accounts/forget":
1243
+ return self._forget(cfg, form)
1115
1244
  if path == "/accounts/add-folder":
1116
1245
  return self._account_add_folder(cfg, form)
1117
1246
  if path == "/notices/dismiss":
@@ -1265,6 +1394,22 @@ class App:
1265
1394
  def restart(self) -> None:
1266
1395
  updates.restart_server(self.port, self.close)
1267
1396
 
1397
+ def _forget(self, cfg: Config, form: dict):
1398
+ """Delete a login's history after the user ticked "I understand" on the confirmation card."""
1399
+ ref_ = form.get("ref", "")
1400
+ if form.get("confirm") != "1":
1401
+ return None, "Tick “I understand” to confirm.", form
1402
+ p = forget.plan(cfg, ref_)
1403
+ if p is None:
1404
+ return None, "That login is no longer recorded on this device.", form
1405
+
1406
+ def job():
1407
+ msg = forget.apply(cfg, p)
1408
+ checks.forget_cache()
1409
+ return msg
1410
+ started = self.jobs.start("Forgetting a login", job)
1411
+ return ("/settings?ok=job#logins" if started else "/settings"), None, form
1412
+
1268
1413
  def _account_add_folder(self, cfg: Config, form: dict):
1269
1414
  """Add a folder the watcher found: pick a free name from the folder, then install its hook."""
1270
1415
  folder = _expand(form.get("folder", ""))
@@ -1425,7 +1570,8 @@ def make_handler(app: App):
1425
1570
  body, err = page_day(app, cfg, day)
1426
1571
  return self._page(f"{day:%d %b %Y}", body, "day", q.get("ok"), err)
1427
1572
  if url.path == "/settings":
1428
- return self._page("Settings", page_settings(app, cfg), "settings", q.get("ok"))
1573
+ forget_ref = q.get("forget") if re.fullmatch(r"[0-9a-f]{12}", q.get("forget", "")) else None
1574
+ return self._page("Settings", page_settings(app, cfg, forget_ref), "settings", q.get("ok"))
1429
1575
  if url.path == "/sources":
1430
1576
  return self._page("Sources", page_sources(app, cfg), "sources", q.get("ok"))
1431
1577
  return self._send(404, "Not found", "text/plain")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: worklog-for-claude-code
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
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
5
  Author: Urvin
6
6
  License-Expression: MIT
@@ -33,7 +33,7 @@ Provides-Extra: test
33
33
  Requires-Dist: pytest>=7; extra == "test"
34
34
  Provides-Extra: dev
35
35
  Requires-Dist: pytest>=7; extra == "dev"
36
- Requires-Dist: ruff==0.17.0; extra == "dev"
36
+ Requires-Dist: ruff==0.17.3; extra == "dev"
37
37
  Requires-Dist: build; extra == "dev"
38
38
  Dynamic: license-file
39
39
 
@@ -5,7 +5,9 @@ tests/test_accounts.py
5
5
  tests/test_chats.py
6
6
  tests/test_checks.py
7
7
  tests/test_demo.py
8
+ tests/test_forget.py
8
9
  tests/test_platforms.py
10
+ tests/test_syncstatus.py
9
11
  tests/test_updates.py
10
12
  tests/test_viz.py
11
13
  tests/test_watcher.py
@@ -17,6 +19,7 @@ worklog/cli.py
17
19
  worklog/collector.py
18
20
  worklog/config.py
19
21
  worklog/demo.py
22
+ worklog/forget.py
20
23
  worklog/hooks.py
21
24
  worklog/identity.py
22
25
  worklog/locks.py
@@ -25,6 +28,7 @@ worklog/redact.py
25
28
  worklog/report.py
26
29
  worklog/services.py
27
30
  worklog/store.py
31
+ worklog/syncstatus.py
28
32
  worklog/updates.py
29
33
  worklog/viz.py
30
34
  worklog/watcher.py
@@ -4,7 +4,7 @@ tomli>=2.0
4
4
 
5
5
  [dev]
6
6
  pytest>=7
7
- ruff==0.17.0
7
+ ruff==0.17.3
8
8
  build
9
9
 
10
10
  [test]