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.
- {worklog_for_claude_code-0.3.0/worklog_for_claude_code.egg-info → worklog_for_claude_code-0.3.1}/PKG-INFO +2 -2
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/pyproject.toml +2 -2
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_chats.py +2 -2
- worklog_for_claude_code-0.3.1/tests/test_forget.py +170 -0
- worklog_for_claude_code-0.3.1/tests/test_syncstatus.py +116 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/__init__.py +1 -1
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/collector.py +4 -1
- worklog_for_claude_code-0.3.1/worklog/forget.py +211 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/identity.py +10 -1
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/store.py +5 -0
- worklog_for_claude_code-0.3.1/worklog/syncstatus.py +148 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/web.py +162 -16
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1/worklog_for_claude_code.egg-info}/PKG-INFO +2 -2
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog_for_claude_code.egg-info/SOURCES.txt +4 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog_for_claude_code.egg-info/requires.txt +1 -1
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/LICENSE +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/README.md +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/setup.cfg +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_accounts.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_checks.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_demo.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_platforms.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_updates.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_viz.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_watcher.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/tests/test_worklog.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/chats.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/checks.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/cli.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/config.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/demo.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/hooks.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/locks.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/parser.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/redact.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/report.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/services.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/updates.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/viz.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.1}/worklog/watcher.py +0 -0
- {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
- {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
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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't be undone" in card and "Claude Code'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")
|
|
@@ -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
|
-
|
|
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
|
|
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'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'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'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's own files, your other accounts, and records synced from your other
|
|
947
|
+
computers. The old versions stay in your data repo'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'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
|
|
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
|
|
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
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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'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
|
-
|
|
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'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
|
-
|
|
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:.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|