worklog-for-claude-code 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- worklog/cli.py +249 -0
- worklog/collector.py +197 -0
- worklog/config.py +294 -0
- worklog/demo.py +214 -0
- worklog/hooks.py +78 -0
- worklog/identity.py +238 -0
- worklog/parser.py +180 -0
- worklog/redact.py +39 -0
- worklog/report.py +229 -0
- worklog/services.py +139 -0
- worklog/store.py +135 -0
- worklog/web.py +776 -0
- worklog_for_claude_code-0.1.0.dist-info/METADATA +112 -0
- worklog_for_claude_code-0.1.0.dist-info/RECORD +19 -0
- worklog_for_claude_code-0.1.0.dist-info/WHEEL +5 -0
- worklog_for_claude_code-0.1.0.dist-info/entry_points.txt +2 -0
- worklog_for_claude_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- worklog_for_claude_code-0.1.0.dist-info/top_level.txt +1 -0
worklog/config.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Configuration loading and validation.
|
|
2
|
+
|
|
3
|
+
Config lives at ~/.config/claude-worklog/config.toml (XDG_CONFIG_HOME respected).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
if sys.version_info >= (3, 11):
|
|
15
|
+
import tomllib
|
|
16
|
+
else: # Ubuntu 22.04 ships Python 3.10
|
|
17
|
+
import tomli as tomllib
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _xdg(var: str, default: str) -> Path:
|
|
21
|
+
return Path(os.environ.get(var) or Path.home() / default)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
CONFIG_PATH = _xdg("XDG_CONFIG_HOME", ".config") / "claude-worklog" / "config.toml"
|
|
25
|
+
STATE_DIR = _xdg("XDG_STATE_HOME", ".local/state") / "claude-worklog" # logs, lock
|
|
26
|
+
DATA_DIR = _xdg("XDG_DATA_HOME", ".local/share") / "claude-worklog" # summarizer cwd
|
|
27
|
+
SUMMARIZER_CWD = DATA_DIR / "summarizer"
|
|
28
|
+
|
|
29
|
+
# Claude Code's default config dir. For this one we must NOT set CLAUDE_CONFIG_DIR,
|
|
30
|
+
# because the default install keeps some state outside it (e.g. ~/.claude.json).
|
|
31
|
+
DEFAULT_CLAUDE_DIR = Path.home() / ".claude"
|
|
32
|
+
|
|
33
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
|
|
34
|
+
_ACCOUNT_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
|
35
|
+
|
|
36
|
+
EXAMPLE_CONFIG = """\
|
|
37
|
+
# claude-worklog configuration
|
|
38
|
+
# Name this device. Must be unique across your machines (letters, digits, _ -).
|
|
39
|
+
device_name = "{device}"
|
|
40
|
+
|
|
41
|
+
# Local clone of your PRIVATE data repo (GitHub, GitLab or Bitbucket all work).
|
|
42
|
+
store_repo = "~/claude-worklog-data"
|
|
43
|
+
|
|
44
|
+
# One [[accounts]] block per Claude account used on THIS device.
|
|
45
|
+
# config_dir is where that account's Claude Code data lives.
|
|
46
|
+
[[accounts]]
|
|
47
|
+
name = "personal"
|
|
48
|
+
config_dir = "~/.claude"
|
|
49
|
+
|
|
50
|
+
# [[accounts]]
|
|
51
|
+
# name = "work"
|
|
52
|
+
# config_dir = "~/.claude-work" # used via: CLAUDE_CONFIG_DIR=~/.claude-work claude
|
|
53
|
+
|
|
54
|
+
# Several accounts can share ONE folder (switching with /logout and /login).
|
|
55
|
+
# Each extra account is matched by the login recorded at session start; name them in
|
|
56
|
+
# the web UI (Settings > Detected accounts). The account without match_* is the
|
|
57
|
+
# folder's default and gets every session that matches no other account.
|
|
58
|
+
# [[accounts]]
|
|
59
|
+
# name = "client"
|
|
60
|
+
# config_dir = "~/.claude"
|
|
61
|
+
# match_account_id = "..." # or: match_email = "you@example.com"
|
|
62
|
+
|
|
63
|
+
[collect]
|
|
64
|
+
misc_label = "R&D / misc" # sessions run outside any git repo
|
|
65
|
+
include_commits = true # read your own commits (git log --author=<your git email>)
|
|
66
|
+
max_prompts_per_session = 30
|
|
67
|
+
max_prompt_chars = 300
|
|
68
|
+
|
|
69
|
+
[report]
|
|
70
|
+
use_ai = true # summarize with `claude -p`; false = plain listing
|
|
71
|
+
summarizer_account = "personal"
|
|
72
|
+
claude_bin = "claude" # use an absolute path if running from cron
|
|
73
|
+
ai_timeout_seconds = 180
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConfigError(Exception):
|
|
78
|
+
"""Raised for missing or invalid configuration."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Account:
|
|
83
|
+
name: str
|
|
84
|
+
config_dir: Path
|
|
85
|
+
# Optional: which login this account is when several accounts share config_dir.
|
|
86
|
+
# Kept in the local config only; never written to records.
|
|
87
|
+
match_account_id: str | None = None
|
|
88
|
+
match_email: str | None = None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def matched(self) -> bool:
|
|
92
|
+
return bool(self.match_account_id or self.match_email)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Config:
|
|
97
|
+
device: str
|
|
98
|
+
store_repo: Path
|
|
99
|
+
accounts: list[Account]
|
|
100
|
+
misc_label: str = "R&D / misc"
|
|
101
|
+
include_commits: bool = True
|
|
102
|
+
max_prompts: int = 30
|
|
103
|
+
max_prompt_chars: int = 300
|
|
104
|
+
use_ai: bool = True
|
|
105
|
+
summarizer_account: str | None = None
|
|
106
|
+
claude_bin: str = "claude"
|
|
107
|
+
ai_timeout: int = 180
|
|
108
|
+
path: Path = field(default=CONFIG_PATH)
|
|
109
|
+
|
|
110
|
+
def account(self, name: str) -> Account | None:
|
|
111
|
+
return next((a for a in self.accounts if a.name == name), None)
|
|
112
|
+
|
|
113
|
+
def accounts_by_dir(self) -> dict[Path, list[Account]]:
|
|
114
|
+
"""Accounts grouped by config folder, in config file order."""
|
|
115
|
+
out: dict[Path, list[Account]] = {}
|
|
116
|
+
for a in self.accounts:
|
|
117
|
+
out.setdefault(a.config_dir, []).append(a)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
def default_account(self, config_dir: Path) -> Account:
|
|
121
|
+
"""The folder's fallback: its first account without match_*, else its first account."""
|
|
122
|
+
group = self.accounts_by_dir()[config_dir]
|
|
123
|
+
return next((a for a in group if not a.matched), group[0])
|
|
124
|
+
|
|
125
|
+
def resolve_account(self, config_dir: Path, account_id: str | None = None,
|
|
126
|
+
email: str | None = None) -> Account:
|
|
127
|
+
"""Configured account for a session in `config_dir` run by the given login."""
|
|
128
|
+
group = self.accounts_by_dir()[config_dir]
|
|
129
|
+
if account_id:
|
|
130
|
+
hit = next((a for a in group if a.match_account_id == account_id), None)
|
|
131
|
+
if hit:
|
|
132
|
+
return hit
|
|
133
|
+
if email:
|
|
134
|
+
hit = next((a for a in group if a.match_email and a.match_email.casefold() == email.casefold()), None)
|
|
135
|
+
if hit:
|
|
136
|
+
return hit
|
|
137
|
+
return self.default_account(config_dir)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _expand(p: str) -> Path:
|
|
141
|
+
return Path(os.path.expandvars(p)).expanduser().resolve()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_name(kind: str, value: object) -> str:
|
|
145
|
+
if not isinstance(value, str) or not _NAME_RE.match(value):
|
|
146
|
+
raise ConfigError(f"{kind} {value!r} must be 1-40 chars of letters, digits, '_' or '-'")
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _int(section: dict, key: str, default: int, lo: int, hi: int) -> int:
|
|
151
|
+
v = section.get(key, default)
|
|
152
|
+
if not isinstance(v, int) or isinstance(v, bool) or not lo <= v <= hi:
|
|
153
|
+
raise ConfigError(f"{key} must be an integer between {lo} and {hi}")
|
|
154
|
+
return v
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _match_id(name: str, v: object) -> str | None:
|
|
158
|
+
if v is None or v == "":
|
|
159
|
+
return None
|
|
160
|
+
if not isinstance(v, str) or not _ACCOUNT_ID_RE.match(v):
|
|
161
|
+
raise ConfigError(f"account {name}: match_account_id must be the account id from Claude Code")
|
|
162
|
+
return v
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _match_email(name: str, v: object) -> str | None:
|
|
166
|
+
if v is None or v == "":
|
|
167
|
+
return None
|
|
168
|
+
if not isinstance(v, str) or len(v) > 254 or not re.match(r"^[^@\s]+@[^@\s]+$", v):
|
|
169
|
+
raise ConfigError(f"account {name}: match_email must be an e-mail address")
|
|
170
|
+
return v
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check_shared_dirs(accounts: list[Account]) -> None:
|
|
174
|
+
"""Accounts sharing a folder must be distinguishable, or sessions would be counted twice."""
|
|
175
|
+
by_dir: dict[Path, list[Account]] = {}
|
|
176
|
+
for a in accounts:
|
|
177
|
+
by_dir.setdefault(a.config_dir, []).append(a)
|
|
178
|
+
for d, group in by_dir.items():
|
|
179
|
+
if len(group) < 2:
|
|
180
|
+
continue
|
|
181
|
+
defaults = [a.name for a in group if not a.matched]
|
|
182
|
+
if len(defaults) > 1:
|
|
183
|
+
raise ConfigError(f"accounts {', '.join(defaults)} share {d}: all but one need match_account_id "
|
|
184
|
+
"or match_email (the one without is that folder's default)")
|
|
185
|
+
ids = [a.match_account_id for a in group if a.match_account_id]
|
|
186
|
+
emails = [a.match_email.casefold() for a in group if a.match_email]
|
|
187
|
+
if len(set(ids)) != len(ids) or len(set(emails)) != len(emails):
|
|
188
|
+
raise ConfigError(f"two accounts in {d} match the same login")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def load_config(path: Path = CONFIG_PATH) -> Config:
|
|
192
|
+
if not path.exists():
|
|
193
|
+
raise ConfigError(f"No config at {path}. Run `claude-worklog init` first.")
|
|
194
|
+
try:
|
|
195
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
196
|
+
except (tomllib.TOMLDecodeError, OSError) as exc:
|
|
197
|
+
raise ConfigError(f"Cannot read {path}: {exc}") from exc
|
|
198
|
+
|
|
199
|
+
device = _check_name("device_name", raw.get("device_name"))
|
|
200
|
+
if not isinstance(raw.get("store_repo"), str):
|
|
201
|
+
raise ConfigError("store_repo is required")
|
|
202
|
+
|
|
203
|
+
accounts: list[Account] = []
|
|
204
|
+
for item in raw.get("accounts", []):
|
|
205
|
+
if not isinstance(item, dict) or not isinstance(item.get("config_dir"), str):
|
|
206
|
+
raise ConfigError("each [[accounts]] needs name and config_dir")
|
|
207
|
+
name = _check_name("account name", item.get("name"))
|
|
208
|
+
accounts.append(Account(name, _expand(item["config_dir"]),
|
|
209
|
+
_match_id(name, item.get("match_account_id")),
|
|
210
|
+
_match_email(name, item.get("match_email"))))
|
|
211
|
+
if not accounts:
|
|
212
|
+
raise ConfigError("configure at least one [[accounts]] block")
|
|
213
|
+
if len({a.name for a in accounts}) != len(accounts):
|
|
214
|
+
raise ConfigError("account names must be unique")
|
|
215
|
+
_check_shared_dirs(accounts)
|
|
216
|
+
|
|
217
|
+
col = raw.get("collect", {})
|
|
218
|
+
rep = raw.get("report", {})
|
|
219
|
+
cfg = Config(
|
|
220
|
+
device=device,
|
|
221
|
+
store_repo=_expand(raw["store_repo"]),
|
|
222
|
+
accounts=accounts,
|
|
223
|
+
misc_label=str(col.get("misc_label", "R&D / misc")),
|
|
224
|
+
include_commits=bool(col.get("include_commits", True)),
|
|
225
|
+
max_prompts=_int(col, "max_prompts_per_session", 30, 0, 500),
|
|
226
|
+
max_prompt_chars=_int(col, "max_prompt_chars", 300, 20, 5000),
|
|
227
|
+
use_ai=bool(rep.get("use_ai", True)),
|
|
228
|
+
summarizer_account=rep.get("summarizer_account") or accounts[0].name,
|
|
229
|
+
claude_bin=str(rep.get("claude_bin", "claude")),
|
|
230
|
+
ai_timeout=_int(rep, "ai_timeout_seconds", 180, 10, 1800),
|
|
231
|
+
path=path,
|
|
232
|
+
)
|
|
233
|
+
if cfg.account(cfg.summarizer_account) is None:
|
|
234
|
+
raise ConfigError(f"summarizer_account {cfg.summarizer_account!r} is not a configured account")
|
|
235
|
+
return cfg
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def example_config() -> str:
|
|
239
|
+
host = re.sub(r"[^A-Za-z0-9_-]", "-", socket.gethostname())[:40] or "device"
|
|
240
|
+
return EXAMPLE_CONFIG.format(device=host)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------ writing
|
|
244
|
+
def home_relative(p: Path | str) -> str:
|
|
245
|
+
s, home = str(p), str(Path.home())
|
|
246
|
+
return "~" + s[len(home):] if s == home or s.startswith(home + "/") else s
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def under_home(p: Path, allow_home: bool = False) -> bool:
|
|
250
|
+
home = Path.home().resolve()
|
|
251
|
+
return (allow_home and p == home) or home in p.parents
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def dump_config(cfg: Config) -> str:
|
|
255
|
+
import json
|
|
256
|
+
|
|
257
|
+
def q(s: object) -> str:
|
|
258
|
+
return json.dumps(str(s), ensure_ascii=False) # JSON strings are valid TOML basic strings
|
|
259
|
+
|
|
260
|
+
def b(v: bool) -> str:
|
|
261
|
+
return "true" if v else "false"
|
|
262
|
+
|
|
263
|
+
out = ["# claude-worklog configuration (also editable from the web UI)",
|
|
264
|
+
f"device_name = {q(cfg.device)}", f"store_repo = {q(home_relative(cfg.store_repo))}", ""]
|
|
265
|
+
for a in cfg.accounts:
|
|
266
|
+
out += ["[[accounts]]", f"name = {q(a.name)}", f"config_dir = {q(home_relative(a.config_dir))}"]
|
|
267
|
+
if a.match_account_id:
|
|
268
|
+
out.append(f"match_account_id = {q(a.match_account_id)}")
|
|
269
|
+
if a.match_email:
|
|
270
|
+
out.append(f"match_email = {q(a.match_email)}")
|
|
271
|
+
out.append("")
|
|
272
|
+
out += ["[collect]", f"misc_label = {q(cfg.misc_label)}", f"include_commits = {b(cfg.include_commits)}",
|
|
273
|
+
f"max_prompts_per_session = {cfg.max_prompts}", f"max_prompt_chars = {cfg.max_prompt_chars}", "",
|
|
274
|
+
"[report]", f"use_ai = {b(cfg.use_ai)}", f"summarizer_account = {q(cfg.summarizer_account)}",
|
|
275
|
+
f"claude_bin = {q(cfg.claude_bin)}", f"ai_timeout_seconds = {cfg.ai_timeout}", ""]
|
|
276
|
+
return "\n".join(out)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def save_config(cfg: Config, path: Path = CONFIG_PATH) -> None:
|
|
280
|
+
"""Validate by re-loading from a temp file, back up the old file, then swap atomically."""
|
|
281
|
+
import shutil
|
|
282
|
+
|
|
283
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
tmp = path.with_suffix(".toml.tmp")
|
|
285
|
+
tmp.write_text(dump_config(cfg), encoding="utf-8")
|
|
286
|
+
tmp.chmod(0o600)
|
|
287
|
+
try:
|
|
288
|
+
load_config(tmp)
|
|
289
|
+
except ConfigError:
|
|
290
|
+
tmp.unlink(missing_ok=True)
|
|
291
|
+
raise
|
|
292
|
+
if path.exists():
|
|
293
|
+
shutil.copy2(path, path.with_suffix(".toml.bak"))
|
|
294
|
+
os.replace(tmp, path)
|
worklog/demo.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""`claude-worklog demo`: the web UI on a throwaway HOME filled with generated fake data.
|
|
2
|
+
|
|
3
|
+
Nothing here reads or writes the real home folder. The generator only writes below the
|
|
4
|
+
fake home it is given, and the UI runs as a subprocess whose HOME and XDG_* variables
|
|
5
|
+
point there, so every path the package computes at import time (config, state, data repo,
|
|
6
|
+
Claude folders, ~/.gitconfig) lands inside the temporary folder. systemd calls are
|
|
7
|
+
disabled through services.DEMO_ENV, and the temporary folder is deleted on exit.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import random
|
|
14
|
+
import shutil
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from datetime import date, datetime, timedelta
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .report import project_key
|
|
23
|
+
from .services import DEMO_ENV
|
|
24
|
+
|
|
25
|
+
DEVICES = ("laptop", "desktop") # the demo runs as "laptop"; "desktop" plays another synced machine
|
|
26
|
+
MODELS = ("claude-sonnet-5", "claude-opus-5-5", "claude-haiku-4-5")
|
|
27
|
+
|
|
28
|
+
# name, host, slug (None = outside any git repo), account, weight, prompts, files, commits, summary bullets
|
|
29
|
+
PROJECTS = [
|
|
30
|
+
{"name": "storefront", "host": "github.com", "slug": "acme-shop/storefront", "account": "work", "weight": 5,
|
|
31
|
+
"prompts": ["add a coupon field to the checkout form",
|
|
32
|
+
"why does the cart badge not update after removing an item?",
|
|
33
|
+
"write tests for the price formatter", "make the product grid responsive on small screens",
|
|
34
|
+
"refactor the cart store to use a reducer"],
|
|
35
|
+
"files": ["src/checkout/CouponField.tsx", "src/cart/store.ts", "src/lib/price.ts", "src/lib/price.test.ts",
|
|
36
|
+
"src/products/Grid.tsx"],
|
|
37
|
+
"commits": ["Add coupon field to checkout", "Fix stale cart badge count", "Test price formatting edge cases",
|
|
38
|
+
"Responsive product grid"],
|
|
39
|
+
"bullets": ["Added a coupon field to checkout with inline validation.",
|
|
40
|
+
"Fixed the cart badge not refreshing after an item was removed.",
|
|
41
|
+
"Covered the price formatter with tests for rounding and currencies."]},
|
|
42
|
+
{"name": "payments-api", "host": "gitlab.com", "slug": "acme-shop/payments-api", "account": "work", "weight": 4,
|
|
43
|
+
"prompts": ["add idempotency keys to the refund endpoint", "explain this retry loop in the webhook worker",
|
|
44
|
+
"the settlement job times out on large batches, find out why", "add a migration for refund reasons"],
|
|
45
|
+
"files": ["app/refunds/views.py", "app/webhooks/worker.py", "app/settlement/jobs.py",
|
|
46
|
+
"migrations/0042_refund_reason.py", "tests/test_refunds.py"],
|
|
47
|
+
"commits": ["Idempotent refunds", "Batch settlement queries", "Add refund reason column"],
|
|
48
|
+
"bullets": ["Made the refund endpoint idempotent using request keys.",
|
|
49
|
+
"Traced settlement timeouts to per-row queries and batched them.",
|
|
50
|
+
"Added a migration and tests for refund reasons."]},
|
|
51
|
+
{"name": "infra", "host": "github.com", "slug": "acme-shop/infra", "account": "work", "weight": 2,
|
|
52
|
+
"prompts": ["bump the staging node pool and add an autoscaling rule",
|
|
53
|
+
"write a runbook for rotating the DB password"],
|
|
54
|
+
"files": ["terraform/staging/nodes.tf", "docs/runbooks/rotate-db-password.md"],
|
|
55
|
+
"commits": ["Autoscale staging node pool", "Runbook: DB password rotation"],
|
|
56
|
+
"bullets": ["Added autoscaling to the staging node pool.", "Wrote a runbook for rotating the database password."]},
|
|
57
|
+
{"name": "trail-map", "host": "github.com", "slug": "demo-user/trail-map", "account": "personal", "weight": 3,
|
|
58
|
+
"prompts": ["render GPX tracks on the map with elevation colors", "cache map tiles offline",
|
|
59
|
+
"add a dark theme toggle"],
|
|
60
|
+
"files": ["src/map/tracks.js", "src/map/tiles.js", "src/ui/theme.css"],
|
|
61
|
+
"commits": ["Color GPX tracks by elevation", "Offline tile cache", "Dark theme"],
|
|
62
|
+
"bullets": ["Rendered GPX tracks colored by elevation.", "Added an offline cache for map tiles."]},
|
|
63
|
+
{"name": "R&D / misc", "host": None, "slug": None, "account": "personal", "weight": 2, "folder": "scratch",
|
|
64
|
+
"prompts": ["compare three ways to debounce a search box", "summarize the tradeoffs of SQLite WAL mode",
|
|
65
|
+
"write a quick script to rename photos by date"],
|
|
66
|
+
"files": ["~/scratch/debounce.js", "~/scratch/rename_photos.py"],
|
|
67
|
+
"commits": [],
|
|
68
|
+
"bullets": ["Compared debounce approaches for a search box.", "Read up on SQLite WAL mode tradeoffs."]},
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def demo_env(home: Path) -> dict[str, str]:
|
|
73
|
+
"""Environment for processes that must only ever see the fake home."""
|
|
74
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith(("XDG_", "CLAUDE_", "GIT_"))}
|
|
75
|
+
env.update(HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"), XDG_STATE_HOME=str(home / ".local/state"),
|
|
76
|
+
XDG_DATA_HOME=str(home / ".local/share"), XDG_CACHE_HOME=str(home / ".cache"),
|
|
77
|
+
GIT_CONFIG_NOSYSTEM="1", PYTHONUNBUFFERED="1")
|
|
78
|
+
env[DEMO_ENV] = "1"
|
|
79
|
+
return env
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _config(home: Path, store: Path) -> str:
|
|
83
|
+
# Absolute paths: the config is also loaded by tests that run with the real HOME.
|
|
84
|
+
return f"""# claude-worklog DEMO configuration (generated, temporary)
|
|
85
|
+
device_name = "{DEVICES[0]}"
|
|
86
|
+
store_repo = {json.dumps(str(store))}
|
|
87
|
+
|
|
88
|
+
[[accounts]]
|
|
89
|
+
name = "personal"
|
|
90
|
+
config_dir = {json.dumps(str(home / ".claude"))}
|
|
91
|
+
|
|
92
|
+
[[accounts]]
|
|
93
|
+
name = "work"
|
|
94
|
+
config_dir = {json.dumps(str(home / ".claude-work"))}
|
|
95
|
+
|
|
96
|
+
[collect]
|
|
97
|
+
misc_label = "R&D / misc"
|
|
98
|
+
|
|
99
|
+
[report]
|
|
100
|
+
use_ai = false
|
|
101
|
+
summarizer_account = "personal"
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _record(rng: random.Random, proj: dict, day: date, device: str, n: int, now: datetime) -> dict | None:
|
|
106
|
+
latest = now.hour - 1 if day == now.date() else 19
|
|
107
|
+
if latest < 8:
|
|
108
|
+
return None # "today" before working hours: no session has happened yet
|
|
109
|
+
start = datetime(day.year, day.month, day.day, rng.randint(8, latest), rng.randint(0, 59)).astimezone()
|
|
110
|
+
minutes = rng.randint(12, 150)
|
|
111
|
+
end = start + timedelta(minutes=minutes)
|
|
112
|
+
model = rng.choices(MODELS, weights=(6, 2, 2))[0]
|
|
113
|
+
scale = {"claude-opus-5-5": 1.4, "claude-haiku-4-5": 0.6}.get(model, 1.0)
|
|
114
|
+
tokens = {"input": int(rng.randint(2_000, 30_000) * scale), "output": int(rng.randint(8_000, 90_000) * scale),
|
|
115
|
+
"cache_creation": int(rng.randint(40_000, 300_000) * scale),
|
|
116
|
+
"cache_read": int(rng.randint(400_000, 4_000_000) * scale)}
|
|
117
|
+
prompts = rng.sample(proj["prompts"], k=min(len(proj["prompts"]), rng.randint(1, 3)))
|
|
118
|
+
files = sorted(rng.sample(proj["files"], k=min(len(proj["files"]), rng.randint(1, 3))))
|
|
119
|
+
commits = [{"hash": f"{rng.getrandbits(28):07x}", "subject": s}
|
|
120
|
+
for s in rng.sample(proj["commits"], k=min(len(proj["commits"]), rng.randint(0, 2)))]
|
|
121
|
+
branches = ["main"] if not proj["slug"] else sorted({"main", rng.choice(["main", "feature/demo", "fix/demo"])})
|
|
122
|
+
if proj["slug"]:
|
|
123
|
+
project = {"kind": "git", "name": proj["name"], "path": f"~/code/{proj['name']}", "branches": branches,
|
|
124
|
+
"remote": {"host": proj["host"], "slug": proj["slug"]}}
|
|
125
|
+
else:
|
|
126
|
+
project = {"kind": "misc", "name": proj["name"], "path": f"~/{proj['folder']}", "folder": proj["folder"],
|
|
127
|
+
"branches": [], "remote": None}
|
|
128
|
+
return {
|
|
129
|
+
"schema": 1, "session_id": f"demo-{day:%Y%m%d}-{device}-{n:02d}", "date": day.isoformat(),
|
|
130
|
+
"account": proj["account"], "device": device, "project": project,
|
|
131
|
+
"start": start.isoformat(timespec="seconds"), "end": end.isoformat(timespec="seconds"),
|
|
132
|
+
"span_minutes": minutes, "api_calls": rng.randint(8, 120), "tokens": tokens,
|
|
133
|
+
"tokens_by_model": {model: tokens}, "prompt_count": len(prompts), "prompts": prompts,
|
|
134
|
+
"files_touched": files, "tool_calls": {"Read": rng.randint(3, 40), "Edit": rng.randint(1, 20),
|
|
135
|
+
"Bash": rng.randint(0, 15)},
|
|
136
|
+
"commits": commits if proj["slug"] else [],
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def generate(home: Path, days: int = 14, seed: int = 7, now: datetime | None = None) -> Path:
|
|
141
|
+
"""Write a fake HOME: config, empty Claude folders and a data repo with `days` of records.
|
|
142
|
+
|
|
143
|
+
Returns the config file path. Only ever writes below `home`.
|
|
144
|
+
"""
|
|
145
|
+
now = now or datetime.now()
|
|
146
|
+
rng = random.Random(seed)
|
|
147
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
for sub in (".claude/projects", ".claude-work/projects"):
|
|
149
|
+
(home / sub).mkdir(parents=True, exist_ok=True)
|
|
150
|
+
store = home / "claude-worklog-data"
|
|
151
|
+
store.mkdir(exist_ok=True)
|
|
152
|
+
subprocess.run(["git", "init", "-q", str(store)], check=True, env=demo_env(home), timeout=30)
|
|
153
|
+
cfg_path = home / ".config" / "claude-worklog" / "config.toml"
|
|
154
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
cfg_path.write_text(_config(home, store), encoding="utf-8")
|
|
156
|
+
cfg_path.chmod(0o600)
|
|
157
|
+
|
|
158
|
+
weights = [p["weight"] for p in PROJECTS]
|
|
159
|
+
for back in range(days):
|
|
160
|
+
day = now.date() - timedelta(days=back)
|
|
161
|
+
weekend = day.weekday() >= 5
|
|
162
|
+
day_projects: dict[str, dict] = {}
|
|
163
|
+
for device in DEVICES:
|
|
164
|
+
count = rng.randint(0, 2) if weekend else rng.randint(1, 4)
|
|
165
|
+
for n in range(count):
|
|
166
|
+
proj = PROJECTS[-2 + rng.randint(0, 1)] if weekend else rng.choices(PROJECTS, weights=weights)[0]
|
|
167
|
+
rec = _record(rng, proj, day, device, n, now)
|
|
168
|
+
if rec is None:
|
|
169
|
+
continue
|
|
170
|
+
rel = store / "records" / day.isoformat() / device / rec["account"] / f"{rec['session_id']}.json"
|
|
171
|
+
rel.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
rel.write_text(json.dumps(rec, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
173
|
+
day_projects[project_key(rec["project"])] = proj
|
|
174
|
+
# Past days have finished summaries; today shows raw prompts, as it would before the nightly run.
|
|
175
|
+
if back > 0 and day_projects:
|
|
176
|
+
summaries = {key: {"bullets": [f"- {b}" for b in p["bullets"]], "source": "ai"}
|
|
177
|
+
for key, p in day_projects.items()}
|
|
178
|
+
generated = datetime(day.year, day.month, day.day, 23, 30).astimezone()
|
|
179
|
+
meta = {"date": day.isoformat(), "device": DEVICES[0], "summaries": summaries,
|
|
180
|
+
"generated_at": generated.isoformat(timespec="seconds")}
|
|
181
|
+
out = store / "reports" / day.isoformat() / f"{DEVICES[0]}.json"
|
|
182
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
out.write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
184
|
+
return cfg_path
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run(port: int, seed: int = 7) -> int:
|
|
188
|
+
"""Generate demo data in a temporary folder, serve the UI until Ctrl+C, then delete it."""
|
|
189
|
+
base = Path(tempfile.mkdtemp(prefix="claude-worklog-demo-"))
|
|
190
|
+
home = base / "home"
|
|
191
|
+
try:
|
|
192
|
+
generate(home, seed=seed)
|
|
193
|
+
print(f"Demo data (fake, temporary): {base}")
|
|
194
|
+
print(f"Open http://localhost:{port} (Ctrl+C to stop; the folder is deleted afterwards)", flush=True)
|
|
195
|
+
proc = subprocess.Popen([sys.executable, "-m", "worklog.cli", "serve", "--port", str(port)],
|
|
196
|
+
env=demo_env(home), cwd=home)
|
|
197
|
+
old = signal.signal(signal.SIGTERM, _interrupt) # `kill` cleans up like Ctrl+C does
|
|
198
|
+
try:
|
|
199
|
+
return proc.wait()
|
|
200
|
+
except KeyboardInterrupt:
|
|
201
|
+
proc.terminate() # no-op if Ctrl+C already stopped it (the terminal signals both)
|
|
202
|
+
try:
|
|
203
|
+
proc.wait(timeout=10)
|
|
204
|
+
except subprocess.TimeoutExpired:
|
|
205
|
+
proc.kill()
|
|
206
|
+
return 0
|
|
207
|
+
finally:
|
|
208
|
+
signal.signal(signal.SIGTERM, old)
|
|
209
|
+
finally:
|
|
210
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _interrupt(signum, frame):
|
|
214
|
+
raise KeyboardInterrupt
|
worklog/hooks.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Install/inspect the Claude Code hooks for an account (shared by CLI and web UI).
|
|
2
|
+
|
|
3
|
+
One command handles both events; it reads `hook_event_name` from stdin:
|
|
4
|
+
* SessionStart: records which account is logged in (for accounts sharing a folder)
|
|
5
|
+
* SessionEnd: starts a background collect
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from .config import Account
|
|
15
|
+
|
|
16
|
+
HOOK_MARKER = "claude-worklog hook"
|
|
17
|
+
HOOK_EVENTS = ("SessionStart", "SessionEnd")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def hook_command() -> str:
|
|
21
|
+
exe = shutil.which("claude-worklog")
|
|
22
|
+
return f"{exe} hook" if exe else f"{sys.executable} -m worklog.cli hook # {HOOK_MARKER}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_ours(command: object) -> bool:
|
|
26
|
+
return isinstance(command, str) and (HOOK_MARKER in command or "worklog.cli hook" in command)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _events_in(settings: object) -> set[str]:
|
|
30
|
+
hooks = settings.get("hooks") if isinstance(settings, dict) else None
|
|
31
|
+
found = set()
|
|
32
|
+
for event in HOOK_EVENTS:
|
|
33
|
+
groups = hooks.get(event) if isinstance(hooks, dict) else None
|
|
34
|
+
for group in groups if isinstance(groups, list) else []:
|
|
35
|
+
entries = group.get("hooks") if isinstance(group, dict) else None
|
|
36
|
+
if any(isinstance(h, dict) and _is_ours(h.get("command")) for h in entries or []):
|
|
37
|
+
found.add(event)
|
|
38
|
+
return found
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def installed_events(account: Account) -> set[str]:
|
|
42
|
+
"""Which of our hook events are present in the account's settings.json."""
|
|
43
|
+
try:
|
|
44
|
+
return _events_in(json.loads((account.config_dir / "settings.json").read_text(encoding="utf-8")))
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
return set()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def hook_installed(account: Account) -> bool:
|
|
50
|
+
return installed_events(account) >= set(HOOK_EVENTS)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def install_hook(account: Account) -> tuple[bool, str]:
|
|
54
|
+
"""Idempotently add the missing hooks. Backs up settings.json first. Returns (ok, message)."""
|
|
55
|
+
path = account.config_dir / "settings.json"
|
|
56
|
+
try:
|
|
57
|
+
settings = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
58
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
59
|
+
return False, f"{account.name}: {path} is not valid JSON ({exc}); nothing changed"
|
|
60
|
+
if not isinstance(settings, dict):
|
|
61
|
+
return False, f"{account.name}: {path} is not a JSON object; nothing changed"
|
|
62
|
+
missing = [ev for ev in HOOK_EVENTS if ev not in _events_in(settings)]
|
|
63
|
+
if not missing:
|
|
64
|
+
return True, f"{account.name}: hooks already installed"
|
|
65
|
+
hooks = settings.setdefault("hooks", {})
|
|
66
|
+
if not isinstance(hooks, dict) or any(not isinstance(hooks.get(ev, []), list) for ev in missing):
|
|
67
|
+
return False, f"{account.name}: unexpected \"hooks\" layout in {path}; nothing changed"
|
|
68
|
+
if path.exists():
|
|
69
|
+
shutil.copy2(path, path.with_name(f"settings.json.bak-{datetime.now():%Y%m%d%H%M%S}"))
|
|
70
|
+
for ev in missing:
|
|
71
|
+
hooks.setdefault(ev, []).append({"hooks": [{"type": "command", "command": hook_command()}]})
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
tmp = path.with_suffix(".json.tmp")
|
|
74
|
+
tmp.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
75
|
+
if path.exists():
|
|
76
|
+
shutil.copymode(path, tmp) # keep the original file permissions
|
|
77
|
+
tmp.replace(path)
|
|
78
|
+
return True, f"{account.name}: {', '.join(missing)} hook installed in {path}"
|