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/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}"