pyworklog 0.3.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/cli.py ADDED
@@ -0,0 +1,1160 @@
1
+ #!/usr/bin/env python3
2
+ """worklog (wl): SQLite-backed worklog tool, todo.sh-style CLI.
3
+
4
+ Usage examples:
5
+ wl init # init DB
6
+ wl add "research X" -k task -p A -t work,P0 --proj dev_tooling
7
+ wl add "Dev tooling" -k project --parent 12
8
+ wl ls # list open items
9
+ wl ls --kind task --tag P0
10
+ wl tree # full tree
11
+ wl tree --kind project
12
+ wl show 42 # detail + log + props + tags + links
13
+ wl log 42 "reviewed A's notes, found..."
14
+ wl done 42
15
+ wl defer 42 2026-06-01
16
+ wl start 42 / wl stop 42 # CLOCK in/out (writes log)
17
+ wl link 42 "Dev tooling" # add vault wikilink
18
+ wl set 42 owner xyb # add custom prop
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from importlib.metadata import version as _pkg_version, PackageNotFoundError as _PackageNotFoundError
23
+ try:
24
+ __version__ = _pkg_version("pyworklog")
25
+ except _PackageNotFoundError: # pragma: no cover -- only hit when running source w/o `uv sync`
26
+ __version__ = "0.0.0+unknown"
27
+
28
+ import argparse
29
+ import os
30
+ import sqlite3
31
+ import sys
32
+ from datetime import datetime, timedelta
33
+ from pathlib import Path
34
+
35
+ from .xdg import _xdg_data_home, _xdg_config_home, _resolve_db_path, _resolve_aliases_path
36
+ from . import render
37
+ from .render import (
38
+ _RICH_AVAIL, _THEME_KEYS, THEMES, _STATUS_STYLE, _PRI_STYLE,
39
+ _resolve_color, _detect_bg_is_dark, _resolve_theme, _init_console,
40
+ out, _c, _hl, _node_line, _snippet, _print_truncation_hint,
41
+ )
42
+ # backward-compat: cli._CONSOLE is a property-like passthrough so existing
43
+ # `wl._CONSOLE` reads in tests/main() see the live render._CONSOLE.
44
+ # (For writes, use `_init_console()` — never bind cli._CONSOLE directly.)
45
+ from .completion import (
46
+ cmd_print_completion,
47
+ _generate_fish_completion,
48
+ _generate_bash_completion,
49
+ _generate_zsh_completion,
50
+ )
51
+ from .queries import (
52
+ _insert_log,
53
+ _node_tags,
54
+ _check_ids_exist,
55
+ _upsert_prop,
56
+ _status_filter_sql,
57
+ _project_members,
58
+ _ancestors_chain,
59
+ _node_bucket,
60
+ _node_project,
61
+ _node_plan,
62
+ _sec_group,
63
+ _collect_descendants,
64
+ _has_tag,
65
+ _node_clock_min,
66
+ _node_exists,
67
+ )
68
+ from .helpers import GENERIC_TAGS # noqa: F401
69
+ from .helpers import (
70
+ _fmt_dur,
71
+ _apply_top_limit,
72
+ _log_full,
73
+ _status_marker,
74
+ _resolve_window,
75
+ _resolve_concrete_date,
76
+ _resolve_at_ts,
77
+ _term_width,
78
+ _truncate_log_body,
79
+ _is_brief,
80
+ _resolve_log_tail,
81
+ _norm_sched,
82
+ _sched_kind,
83
+ _sched_anchor,
84
+ _sched_sort_key,
85
+ _sched_display,
86
+ )
87
+
88
+ DB_PATH = _resolve_db_path()
89
+ ALIASES_PATH = _resolve_aliases_path()
90
+ MIGRATIONS_DIR = Path(__file__).parent / "migrations"
91
+
92
+ # --- rich highlighting (optional dep, auto-detected; missing or non-TTY -> plain text) ---
93
+
94
+
95
+
96
+ # ─── DB helpers (thin wrappers; impl lives in db.py) ───
97
+ from . import db as _db
98
+
99
+
100
+ def db_connect() -> sqlite3.Connection:
101
+ return _db.db_connect(DB_PATH)
102
+
103
+
104
+ def _migration_files() -> list[Path]:
105
+ return _db.migration_files(MIGRATIONS_DIR)
106
+
107
+
108
+ def _db_version(con: sqlite3.Connection) -> int:
109
+ return _db.db_version(con)
110
+
111
+
112
+ def _run_migrations(con: sqlite3.Connection, verbose: bool = False) -> list[Path]:
113
+ return _db.run_migrations(con, MIGRATIONS_DIR, verbose=verbose)
114
+
115
+
116
+ def db_init(con: sqlite3.Connection) -> None:
117
+ _db.run_migrations(con, MIGRATIONS_DIR)
118
+
119
+
120
+ def ensure_db():
121
+ _db.ensure_db(DB_PATH, MIGRATIONS_DIR)
122
+
123
+
124
+ def _load_user_aliases():
125
+ """Read ~/.config/worklog/aliases.ini and return {target_cmd: [alias1, alias2, ...]}.
126
+ Format:
127
+ [aliases]
128
+ d = day
129
+ c = checkin
130
+ Multiple aliases pointing to the same target are merged. Returns {} on failure / missing file.
131
+ """
132
+ import configparser
133
+ # Resolve at call-time so that tests monkeypatching HOME / XDG_CONFIG_HOME
134
+ # see the new path (the module-level ALIASES_PATH is resolved at import).
135
+ path = str(_resolve_aliases_path())
136
+ if not os.path.exists(path):
137
+ return {}
138
+ cfg = configparser.ConfigParser()
139
+ try:
140
+ cfg.read(path, encoding="utf-8")
141
+ except (configparser.Error, OSError):
142
+ return {}
143
+ if "aliases" not in cfg:
144
+ return {}
145
+ out = {}
146
+ for alias, target in cfg["aliases"].items():
147
+ target = target.strip()
148
+ if not target:
149
+ continue
150
+ out.setdefault(target, []).append(alias.strip())
151
+ return out
152
+
153
+
154
+ _USER_ALIASES = None # lazy cache, populated on first build_parser call
155
+
156
+
157
+ def build_parser():
158
+ global _USER_ALIASES
159
+ if _USER_ALIASES is None:
160
+ _USER_ALIASES = _load_user_aliases()
161
+ user_aliases = _USER_ALIASES
162
+
163
+ p = argparse.ArgumentParser(prog="wl", description="worklog: SQLite-backed worklog tool")
164
+ p.add_argument("--version", action="version", version=f"wl {__version__}")
165
+ p.add_argument("--db", metavar="PATH",
166
+ help="override the DB path for this invocation (handy for testing / multiple worklogs); takes precedence over $WORKLOG_DB and the XDG default")
167
+ p.add_argument("--color", choices=["auto", "always", "never"], default=None,
168
+ help="color switch (default auto: enabled on TTY + rich; also reads $WORKLOG_COLOR/$NO_COLOR)")
169
+ p.add_argument("--theme", default=None, choices=["auto"] + list(THEMES),
170
+ metavar="{auto,%s}" % ",".join(THEMES),
171
+ help="color theme (default auto: probe terminal bg, pick dark/light; reads $WORKLOG_THEME; see `wl themes`)")
172
+ p.add_argument("-q", "--brief", action="store_true",
173
+ help="brief output: skip log body/timeline/detail in every command, token-saving for AI")
174
+ p.add_argument("--log-format", choices=["oneline", "full"], default="oneline",
175
+ help="log body render style (default oneline = truncate to terminal width with …; full = expand; applies across wl day/tree/logs/show)")
176
+ p.add_argument("--show-canceled", action="store_true",
177
+ help="show CANCELED nodes (hidden by default; --all also includes them)")
178
+
179
+ # time-window parent parser (reused by changes/summary/logs etc.)
180
+ window = argparse.ArgumentParser(add_help=False)
181
+ window.add_argument("--since", help="YYYY-MM-DD (start)")
182
+ window.add_argument("--until", help="YYYY-MM-DD (end)")
183
+ window.add_argument("--week", help="YYYY-Www (ISO week, overrides since/until)")
184
+ window.add_argument("--month", help="YYYY-MM (overrides since/until)")
185
+
186
+ _real_sub = p.add_subparsers(dest="cmd", required=False)
187
+
188
+ # wrap add_parser to inject user aliases (cross-shell uniform: wl d == wl day)
189
+ class _SubWrapper:
190
+ def __init__(self, sub):
191
+ self._sub = sub
192
+ def add_parser(self, name, **kw):
193
+ aliases = list(kw.pop("aliases", []))
194
+ for a in user_aliases.get(name, []):
195
+ if a not in aliases and a != name:
196
+ aliases.append(a)
197
+ if aliases:
198
+ kw["aliases"] = aliases
199
+ # battery-included (DESIGN §35): if no explicit description,
200
+ # use help as the description so `wl <cmd> --help` always
201
+ # has an intro line right after the usage line.
202
+ if "description" not in kw and "help" in kw:
203
+ kw["description"] = kw["help"]
204
+ return self._sub.add_parser(name, **kw)
205
+ def __getattr__(self, k):
206
+ return getattr(self._sub, k)
207
+ sub = _SubWrapper(_real_sub)
208
+
209
+ sub.add_parser("migrate",
210
+ help="apply pending SQL migrations from migrations/NNNN_*.sql (auto-run on every command; this is the explicit form)",
211
+ formatter_class=argparse.RawDescriptionHelpFormatter,
212
+ epilog="""\
213
+ The DB version is tracked via `PRAGMA user_version`. Every migration in
214
+ `migrations/` is named NNNN_*.sql (numeric prefix sorts the apply order);
215
+ files with number > current PRAGMA user_version run in order, each in its
216
+ own transaction, then user_version is bumped.
217
+
218
+ Migrations are auto-applied by `ensure_db()` on every command, so you
219
+ rarely need to invoke this explicitly. Use it to see what's pending or
220
+ to retry after a failed migration.""")
221
+
222
+ sub.add_parser("config",
223
+ help="print resolved configuration: DB path, aliases path, XDG dirs, env vars",
224
+ formatter_class=argparse.RawDescriptionHelpFormatter,
225
+ epilog="""\
226
+ Shows where worklog reads from and how the runtime is configured.
227
+ Useful when:
228
+ - you're not sure which DB `wl` is using ($WORKLOG_DB env vs XDG default)
229
+ - you need to point another tool at the same DB / aliases file
230
+ - the rich highlighting isn't appearing and you want to check theme/env
231
+
232
+ Read-only and side-effect free — does not create the DB if missing.""")
233
+
234
+ sub.add_parser("init",
235
+ help="initialize SQLite DB (default ~/.local/share/worklog/worklog.db; skips if it exists)",
236
+ formatter_class=argparse.RawDescriptionHelpFormatter,
237
+ epilog="""Run once on a fresh machine before using wl.
238
+
239
+ DB path resolution:
240
+ 1. --db PATH flag (per-invocation override)
241
+ 2. $WORKLOG_DB env var
242
+ 3. $XDG_DATA_HOME/worklog/worklog.db (default ~/.local/share/worklog/worklog.db)
243
+
244
+ Config (aliases.ini) lives at $XDG_CONFIG_HOME/worklog/aliases.ini (default ~/.config/worklog/aliases.ini).""")
245
+
246
+ a = sub.add_parser("add",
247
+ help="create a new node (task/project/area/meetlog/habit/day...); compound flags let you do add + log + done + sched + link in one shot",
248
+ description="Create a new node (task/project/area/meetlog/habit/day/...). Compound flags support add + log + done + sched + link in one step, replacing several separate commands.",
249
+ formatter_class=argparse.RawDescriptionHelpFormatter,
250
+ epilog="""\
251
+ Common examples:
252
+ # New task (work-task-start preferred path)
253
+ wl add "PoC-3 S3 permissions" -k task -p B -t work,iac --parent 103 --sched today
254
+
255
+ # New project under an area
256
+ wl add "new project" -k project -p A -t work --parent <area_id>
257
+
258
+ # Retrospective entry (create + log + done + closed_at + link, one shot)
259
+ wl add "got something done" -k task -p B \\
260
+ --log "result note (PR#42)" --done --at 14:30 --link "vault doc name" --sched today
261
+
262
+ # meetlog placeholder
263
+ wl add "[meetlog] 09:30 tech sync" -k meetlog -p A -t work,meeting --parent <day_id>
264
+
265
+ Differences from related commands:
266
+ - wl add ... --log + --done one-shot create + log + close. Same as add -> log -> done in three steps.
267
+ - wl tick <id> add a check-in log to an existing habit/task, does not create a new one
268
+ - wl log <id> add a log to an existing task, does not create a new one""")
269
+ a.add_argument("title")
270
+ a.add_argument("-k", "--kind", default="task", help="node kind (default: task)")
271
+ a.add_argument("-p", "--priority", choices=["A", "B", "C"])
272
+ a.add_argument("-t", "--tag", help="comma-separated tags")
273
+ a.add_argument("--proj", help="project (stored as prop)")
274
+ a.add_argument("--parent", type=int, help="parent node id")
275
+ a.add_argument("--status")
276
+ a.add_argument("--scheduled", help="(rough hint, writes node.scheduled_at) scheduled time: YYYY-MM-DD / YYYY-MM / YYYY-Www / YYYY-Qn / YYYY / someday / tomorrow / next-week / next-month / next-quarter")
277
+ a.add_argument("--sched", help="(precise, writes the sched table = visible as planned in `wl day` for that date) date: YYYY-MM-DD / today / yesterday / tomorrow / day-after-tomorrow")
278
+ a.add_argument("--deadline", help="deadline date YYYY-MM-DD")
279
+ a.add_argument("--body", help="optional body text")
280
+ # compound flags: create + log + status + association
281
+ a.add_argument("--log", "-m", help="insert a log entry right after creation (result / output / numbers)")
282
+ a.add_argument("--done", action="store_true", help="mark DONE + write closed_at immediately after creation (retrospective task in one shot)")
283
+ a.add_argument("--at", help="timestamp for --log + (if --done) closed_at (HH:MM / YYYY-MM-DD [HH:MM[:SS]])")
284
+ a.add_argument("--link", help="also attach a vault doc (no .md suffix, same semantics as `wl link`)")
285
+
286
+ g = sub.add_parser("log",
287
+ help="add a log entry to a node (auto TODO -> DOING)",
288
+ description="Add a log entry to a node (progress / event stream). By default auto-progresses TODO to DOING ('logging means working'); suppress with --keep-status. Backfill historical data with --date/--time.",
289
+ formatter_class=argparse.RawDescriptionHelpFormatter,
290
+ epilog="""\
291
+ Common examples:
292
+ wl log 42 "result: PR#13 merged" # current progress
293
+ wl log 42 "..." --date 2026-05-20 # backfill to that day
294
+ wl log 42 "..." --date yesterday --time 14:30 # precise timestamp
295
+ wl log 42 "..." --keep-status # don't change status (e.g. log while WAIT)
296
+
297
+ Differences from related commands:
298
+ - wl tick <id> --note "..." habit check-in, default body = "✓ done"
299
+ - wl add ... --log "..." create a new task + insert a log in one step
300
+ - wl relog #L<id> "new body" rewrite an existing log body / time
301
+ - wl unlog #L<id> delete a log""")
302
+ g.add_argument("id", type=int)
303
+ g.add_argument("body")
304
+ g.add_argument("--date", help="log date: YYYY-MM-DD / today / yesterday / day-before-yesterday / tomorrow / day-after-tomorrow (default: today; for backfilling history)")
305
+ g.add_argument("--time", help="log time HH:MM or HH:MM:SS (with --date, or alone for today)")
306
+ g.add_argument("--keep-status", action="store_true",
307
+ help="do not auto-promote TODO to DOING (default: logging implies 'working on it'; DONE etc. unchanged)")
308
+
309
+ d = sub.add_parser("done",
310
+ help="mark node DONE + closed_at (multiple ids; --log/--at for one-shot log+done)",
311
+ description="Mark node as DONE and write closed_at. Accepts multiple ids. --log/--at combines log + close + timestamp in one step (replaces wl log -> wl done two-step).",
312
+ formatter_class=argparse.RawDescriptionHelpFormatter,
313
+ epilog="""\
314
+ Common examples:
315
+ wl done 42 # mark done
316
+ wl done 42 43 44 # batch
317
+ wl done 42 --log "PR#13 merged" # close + log in one shot
318
+ wl done 42 -m "..." --at 2026-05-30 16:00 # use past timestamp (closed_at + log together)
319
+ wl cancel 42 --log "deprioritized, dropping" # cancel also takes --log/--at
320
+
321
+ Note: running done on an already-DONE node overwrites closed_at (matches cancel behavior).
322
+ Inverse of wl reopen (undo DONE back to TODO).""")
323
+ d.add_argument("ids", type=int, nargs="+", help="node id(s)")
324
+ d.add_argument("--log", "-m", help="add a log (result / output / numbers) right before closing")
325
+ d.add_argument("--at", help="closed_at + log use this timestamp (HH:MM / YYYY-MM-DD [HH:MM[:SS]])")
326
+
327
+ df = sub.add_parser("defer",
328
+ help="defer a task to a future point (LATER + scheduled_at; fuzzy times supported)",
329
+ formatter_class=argparse.RawDescriptionHelpFormatter,
330
+ epilog="""\
331
+ Common examples:
332
+ wl defer 42 2026-06-15 # defer to a precise date
333
+ wl defer 42 next-month # fuzzy
334
+ wl defer 42 2026-Q3 # quarter
335
+ wl defer 42 someday # no scheduled time
336
+
337
+ Differences from wl sched:
338
+ - wl defer -> status=LATER + scheduled_at field (rough hint, does NOT appear as "planned" in wl day on that day)
339
+ - wl sched -> writes to sched table (precise, appears as "planned" in wl day on that day)
340
+ To schedule it as planned for a specific day, use wl sched. defer is for "set aside, vaguely revisit later".""")
341
+ df.add_argument("id", type=int)
342
+ df.add_argument("date", help="scheduled time (precise or fuzzy): YYYY-MM-DD / YYYY-MM / YYYY-Www / YYYY-Qn / YYYY / someday / tomorrow / next-week / next-month / next-quarter")
343
+
344
+ s = sub.add_parser("start",
345
+ help="clock-in to start timing (batch ids; --at to backfill past time)",
346
+ formatter_class=argparse.RawDescriptionHelpFormatter,
347
+ epilog="""\
348
+ Common examples:
349
+ wl start 42 # start timing now (inserts CLOCK_IN log)
350
+ wl start 42 43 # multiple tasks at once (parallel timers)
351
+ wl start 42 --at 09:00 # backfill 9am start (forgot to clock in)
352
+ wl start 42 --at 2026-05-30 14:30 # full ts
353
+
354
+ Related: close with wl stop <id>; see what's running via wl active; wl spent records a CLOCK pair from a duration.""")
355
+ s.add_argument("ids", type=int, nargs="+", help="node id(s)")
356
+ s.add_argument("--at", help="backfill start time: HH:MM (today) / YYYY-MM-DD / YYYY-MM-DD HH:MM[:SS]")
357
+
358
+ st = sub.add_parser("stop",
359
+ help="clock-out to stop timing + compute elapsed (multiple ids; --at to backfill past end)",
360
+ formatter_class=argparse.RawDescriptionHelpFormatter,
361
+ epilog="""\
362
+ Common examples:
363
+ wl stop 42 # stop now, write CLOCK_OUT elapsed=Nmin
364
+ wl stop 42 43 # batch stop
365
+ wl stop 42 --at 11:30 # backfill 11:30 end (must be later than CLOCK_IN)
366
+ wl stop 42 --at 2026-05-30 16:00 # full ts
367
+
368
+ Difference from wl spent: stop pairs with a prior CLOCK_IN; spent creates a pair directly from a duration.""")
369
+ st.add_argument("ids", type=int, nargs="+", help="node id(s)")
370
+ st.add_argument("--at", help="backfill end time (must be later than CLOCK_IN)")
371
+
372
+ sp = sub.add_parser("spent",
373
+ help="record a past time spent (build CLOCK pair from duration, good for retrospective entries)",
374
+ formatter_class=argparse.RawDescriptionHelpFormatter,
375
+ epilog="""\
376
+ Common examples:
377
+ wl spent 42 45 # 45 minutes (start = NOW - 45m, stop = NOW)
378
+ wl spent 42 90m # same, with m suffix
379
+ wl spent 42 1h30m # 1 hour 30 minutes
380
+ wl spent 42 2h # 2 hours
381
+ wl spent 42 30m --at 14:30 # end at 14:30, backfill start at 14:00
382
+
383
+ Difference from wl start/stop: spent builds CLOCK_IN+OUT pair from a duration in one step; no need to start first. Good for "forgot to clock, recording it after the fact".""")
384
+ sp.add_argument("id", type=int, help="node id")
385
+ sp.add_argument("duration", help="duration: 90 / 90m / 1h30m / 2h")
386
+ sp.add_argument("--at", help="end timestamp (default NOW); start = at - duration")
387
+
388
+ ac = sub.add_parser("active",
389
+ help="tasks running right now (open CLOCK_IN) + today's elapsed + latest log",
390
+ description="List tasks that are timing right now (open CLOCK_IN). Shows current session elapsed, today's total, and the most recent log. Good for live focus check and finding tasks you forgot to stop.",
391
+ formatter_class=argparse.RawDescriptionHelpFormatter,
392
+ epilog="""\
393
+ Use cases:
394
+ - Before lunch / a meeting, see which task is still timing
395
+ - Late in the day, find a task you forgot to stop and wrap it up with wl stop <id>
396
+ - When juggling several tasks, confirm current focus
397
+
398
+ Difference from wl day:
399
+ - wl day = full progress for the day (includes done / not-yet-started planned items), for end-of-day review
400
+ - wl active = what's timing right now (open CLOCK_IN), for live focus check
401
+
402
+ Output includes: current session elapsed + today's total (to decide stop or continue) + latest log (context).
403
+ Brief mode -q: id + elapsed only. Full log body: --log-format full.""")
404
+ # ac has no other flags but we keep the variable for future args (e.g. --since to look at past activity)
405
+
406
+ wa = sub.add_parser("wait",
407
+ help="mark WAIT (blocked on others / external input); auto-closes CLOCK; multiple ids",
408
+ formatter_class=argparse.RawDescriptionHelpFormatter,
409
+ epilog="""\
410
+ Common examples:
411
+ wl wait 42 # mark WAIT (suspended)
412
+ wl wait 42 --note "waiting on review" # add a log explaining what we're waiting on
413
+ wl wait 42 43 --note "waiting on approval" # batch
414
+
415
+ Note: marking WAIT auto-closes any open CLOCK_IN (WAIT = suspended, no longer timing). Use wl reopen to revert to TODO.""")
416
+ wa.add_argument("ids", type=int, nargs="+", help="node id(s)")
417
+ wa.add_argument("--note", help="add a log explaining what you're waiting on")
418
+
419
+ ro = sub.add_parser("reopen",
420
+ help="undo DONE/CANCELED/WAIT/LATER back to TODO + clear closed_at (multiple ids)",
421
+ formatter_class=argparse.RawDescriptionHelpFormatter,
422
+ epilog="""\
423
+ Common examples:
424
+ wl reopen 42 # single id
425
+ wl reopen 42 43 # batch
426
+
427
+ Inverse of wl done/cancel. Use when you change your mind and want to restart a task.""")
428
+ ro.add_argument("ids", type=int, nargs="+", help="node id(s)")
429
+
430
+ cx = sub.add_parser("cancel",
431
+ help="mark CANCELED + closed_at (drop / no-longer-doing; parallel to done); --log/--at supported",
432
+ formatter_class=argparse.RawDescriptionHelpFormatter,
433
+ epilog="""\
434
+ Common examples:
435
+ wl cancel 42 # drop it
436
+ wl cancel 42 -m "deprioritized" # close + log reason in one step
437
+ wl cancel 42 --at 2026-05-30 16:00 # closed_at + log use past timestamp
438
+
439
+ Difference from wl done: done = delivered; cancel = dropped, not doing. Both write closed_at and accept --log/--at.
440
+ Difference from wl wait: wait = paused (still planning to do); cancel = not doing it.""")
441
+ cx.add_argument("ids", type=int, nargs="+", help="node id(s)")
442
+ cx.add_argument("--log", "-m", help="add a log explaining why you're canceling")
443
+ cx.add_argument("--at", help="use this timestamp for closed_at + log")
444
+
445
+ ln = sub.add_parser("link",
446
+ help="link a node to a vault doc name (no .md suffix; multiple ids)",
447
+ formatter_class=argparse.RawDescriptionHelpFormatter,
448
+ epilog="""\
449
+ Common examples:
450
+ wl link 42 "Project hub doc" # link
451
+ wl link 42 43 "shared topic" # link multiple ids at once
452
+ # vault doc name matches the [[wikilink]] title (no .md suffix)
453
+
454
+ After linking, wl show <id> displays links: [[doc name]] at the top.
455
+ Design: the knowledge layer (vault) and execution layer (wl) stay decoupled; wl only knows the linked doc name and does not sync content back.""")
456
+ ln.add_argument("ids", type=int, nargs="+", metavar="id", help="node id(s)")
457
+ ln.add_argument("vault_doc")
458
+
459
+ se = sub.add_parser("set",
460
+ help="set/update a custom key=value prop (UDA-style)",
461
+ formatter_class=argparse.RawDescriptionHelpFormatter,
462
+ epilog="""\
463
+ Common examples:
464
+ wl set 42 owner xyb # add owner to a task
465
+ wl set 42 linear ABC-449 # backfill Linear ID
466
+ wl set <day_id> summary "..." # meta prop (but prefer wl recap for end-of-day)
467
+ wl set <day_id> goal "deliver X" # (prefer wl goal: stamps a timestamp)
468
+ wl set <week_id> overview "..." # week overview
469
+ wl set <month_id> top5 "..." # monthly Top5
470
+
471
+ Difference from wl recap/goal: those target the day node and stamp a timestamp; they are convenience aliases for wl set summary/goal.""")
472
+ se.add_argument("id", type=int)
473
+ se.add_argument("key")
474
+ se.add_argument("value")
475
+
476
+ sh = sub.add_parser("show",
477
+ help="full detail + timeline for a node (accepts multiple ids)",
478
+ description="All info on a node: metadata (status/priority/parents/tags/links/props) + timeline (created/scheduled/closed/log merged by time). Timeline defaults to the last 5; use --all-timelines for full expansion.",
479
+ formatter_class=argparse.RawDescriptionHelpFormatter,
480
+ epilog="""\
481
+ Common examples:
482
+ wl show 42 # full detail + last 5 timeline entries
483
+ wl show 42 -q # brief: skip timeline
484
+ wl show 42 --timeline-tail 20 # show a longer timeline
485
+ wl show 42 --all-timelines # full expansion
486
+ wl show 42 --log-format full # do not truncate log body in timeline
487
+
488
+ Differences from related commands:
489
+ - wl show <id> single-node detail + timeline (deep dive on one node)
490
+ - wl focus <id> single node + upstream path + downstream subtree (context view)
491
+ - wl logs --id <id> only log stream for that node (no metadata)""")
492
+ sh.add_argument("ids", type=int, nargs="+", metavar="id", help="node id(s)")
493
+ sh.add_argument("--no-timeline", action="store_true",
494
+ help="skip the timeline; only show meta+tags+links (same as --brief)")
495
+ sh.add_argument("--timeline-tail", type=int, metavar="N",
496
+ help="only show the latest N timeline entries (default 5, with middle elided)")
497
+ sh.add_argument("--all-timelines", action="store_true",
498
+ help="full timeline, no elision")
499
+
500
+ ls = sub.add_parser("ls", help="list nodes (default limit 20; see shell ls -t / -S / -r-style dimensions)",
501
+ formatter_class=argparse.RawDescriptionHelpFormatter,
502
+ epilog="""\
503
+ Common examples (precise queries, shell-ls multi-dimensional):
504
+ wl ls --parent 45 children of #45 (like ls dir/)
505
+ wl ls --kind project only projects
506
+ wl ls --tag work,dev multi-tag AND filter
507
+ wl ls --unscheduled --kind task unscheduled tasks (inbox)
508
+ wl ls --sort created -r --limit 5 5 most-recently-created (like ls -tr -5)
509
+ wl ls --sort updated --limit 10 10 most-recently-logged (like ls -t)
510
+ wl ls --recent 7 anything that changed in the last 7 days
511
+ wl ls --ids 39 41 270 look at specific ids directly (like ls f1 f2)
512
+ wl ls --status WAIT blocked / waiting on others
513
+ wl ls --all remove the 20-row limit + include DONE/CANCELED
514
+
515
+ See also: wl find <q> / wl day / wl active / wl projects (each has a dedicated entry point sharper than ls)""")
516
+ ls.add_argument("--kind", help="filter by kind (task/habit/meetlog/project/area/...)")
517
+ ls.add_argument("--status", help="filter by status (TODO/DOING/DONE/WAIT/LATER/CANCELED)")
518
+ ls.add_argument("--tag", help="comma-separated tags, AND filter")
519
+ ls.add_argument("--parent", type=int, help="only direct children of this node")
520
+ ls.add_argument("--all", action="store_true", help="include DONE/CANCELED + remove the limit cap")
521
+ ls.add_argument("--limit", type=int, metavar="N",
522
+ help="show only the first N (default 20; 0 = no cap)")
523
+ ls.add_argument("--top", type=int, metavar="N",
524
+ help="take the top N under the current sort (often paired with --sort)")
525
+ ls.add_argument("--sort", choices=["pri", "created", "updated", "closed", "scheduled", "title", "id"],
526
+ default="pri",
527
+ help="sort dimension (default pri = priority+id; updated = last log time, like shell ls -t)")
528
+ ls.add_argument("--reverse", "-r", action="store_true",
529
+ help="reverse sort (like shell ls -r); pairs with --sort; default pri reversed = lowest priority first")
530
+ ls.add_argument("--recent", type=int, metavar="N", default=None,
531
+ help="only items changed in the last N days (created / logged / closed)")
532
+ ls.add_argument("--unscheduled", action="store_true",
533
+ help="only items not in sched (use this for 'unscheduled', not --status)")
534
+ ls.add_argument("--ids", type=int, nargs="+", metavar="id",
535
+ help="list specific ids directly, skipping filters (like shell `ls file1 file2`)")
536
+
537
+ tr = sub.add_parser("tree",
538
+ help="tree view of nodes (default: timeline up to today + areas one level, ~30 rows)",
539
+ description="Tree view of nodes. Default: timeline expanded up to today (year -> quarter -> month -> week -> today + today's tasks) + areas one level, ~30 rows to avoid scrolling. Use --root <id> to drill into a node.",
540
+ formatter_class=argparse.RawDescriptionHelpFormatter,
541
+ epilog="""\
542
+ Common examples:
543
+ wl tree # default overview (today + areas one level)
544
+ wl tree --root <area_id> # area -> projects + tasks (default depth 3)
545
+ wl tree --root <project_id> # project subtree (tasks)
546
+ wl tree --root <day_id> --depth 9 # full per-log expansion for a day
547
+ wl tree --depth 9 # full tree (from lifetime; can be large)
548
+ wl tree --by project # flat 2-level: project -> task
549
+ wl tree --by tag # flat 2-level: semantic tag -> node
550
+
551
+ Differences from related commands:
552
+ - wl tree hierarchical browse (default overview; --root to drill)
553
+ - wl day log-date-driven view of a single day (not tied to tree)
554
+ - wl projects list projects as cards (subtask counts, no tree expansion)
555
+ - wl ls --parent <N> flat list of direct children, no recursion""")
556
+ tr.add_argument("--kind")
557
+ tr.add_argument("--proj")
558
+ tr.add_argument("--root", type=int, help="start tree from this node id")
559
+ tr.add_argument("--by", choices=["project", "tag", "direction"], help="regroup by dimension (flat 2-level)")
560
+ tr.add_argument("--depth", type=int, help="max depth")
561
+ tr.add_argument("--no-logs", action="store_true",
562
+ help="don't expand logs under day-node activities (same as --brief)")
563
+ tr.add_argument("--log-tail", type=int, metavar="N",
564
+ help="latest N logs per task in day-node activities (default 3, middle elided)")
565
+ tr.add_argument("--all-logs", action="store_true",
566
+ help="full log expansion in day-node activities, no elision")
567
+
568
+ fo = sub.add_parser("focus",
569
+ help="focus on a node: upstream path + self + downstream subtree",
570
+ formatter_class=argparse.RawDescriptionHelpFormatter,
571
+ epilog="""\
572
+ Common examples:
573
+ wl focus 42 # upstream path + self + direct children
574
+ wl focus 42 --depth 3 # expand 3 levels downstream
575
+ wl focus 42 --related # also include tag-related nodes
576
+
577
+ Related: wl show is self + timeline only; wl ancestors/descendants only go one direction; wl focus combines them.""")
578
+ fo.add_argument("id", type=int)
579
+ fo.add_argument("--depth", type=int, help="max downstream depth")
580
+ fo.add_argument("--related", action="store_true", help="also show tag-related nodes")
581
+
582
+ an = sub.add_parser("ancestors",
583
+ help="upstream path: ancestor chain from root to the node",
584
+ formatter_class=argparse.RawDescriptionHelpFormatter,
585
+ epilog="Example: wl ancestors 42 -> Lifetime / Area / Project / Task. Inverse: wl descendants for the downstream subtree.")
586
+ an.add_argument("id", type=int)
587
+
588
+ de = sub.add_parser("descendants",
589
+ help="downstream subtree: all descendants of a node",
590
+ formatter_class=argparse.RawDescriptionHelpFormatter,
591
+ epilog="Example: wl descendants 7 --depth 2 -> two levels of children under #7. wl tree --root 7 is equivalent but rendered as a tree.")
592
+ de.add_argument("id", type=int)
593
+ de.add_argument("--depth", type=int, help="max depth")
594
+
595
+ pj = sub.add_parser("projects", parents=[window],
596
+ help="list active projects + subtask counts + recent activity",
597
+ description="List all active projects (kind=project, status not DONE/CANCELED) with subtask counts + last log time. --since filters to projects with activity after that date.",
598
+ formatter_class=argparse.RawDescriptionHelpFormatter,
599
+ epilog="""\
600
+ Common examples:
601
+ wl projects # all active projects
602
+ wl projects --since 2026-05-01 # active since May 1
603
+ wl projects --week 2026-W22 # active this week
604
+ wl projects --top 5 # top 5 by priority
605
+ wl projects --all # include DONE/CANCELED projects
606
+
607
+ Differences from related commands:
608
+ - wl projects card view (subtask counts + recent activity)
609
+ - wl tree --by project flat 2-level: project -> linked tasks (includes tag links)
610
+ - wl ls --kind project plain list of project nodes (no card, no subtask stats)""")
611
+ pj.add_argument("--all", action="store_true", help="include DONE/CANCELED projects")
612
+ pj.add_argument("--limit", type=int, metavar="N", help="show only the first N")
613
+ pj.add_argument("--top", type=int, metavar="N",
614
+ help="top N by priority+id (semantics: high-priority active projects)")
615
+
616
+ sub.add_parser("changes", parents=[window],
617
+ help="per-project changes in a time window (added / done / log counts)",
618
+ description="What happened to each project in a time window: tasks added, tasks closed, new log count. Good input for weekly reports and Linear updates.",
619
+ formatter_class=argparse.RawDescriptionHelpFormatter,
620
+ epilog="""\
621
+ Common examples:
622
+ wl changes --week 2026-W22 # this week's changes (per-project)
623
+ wl changes --since 2026-05-01 # changes since May 1
624
+ wl changes --month 2026-05 # whole month
625
+
626
+ Differences from related commands:
627
+ - wl changes change-focused: what was added / closed / how many logs
628
+ - wl summary state-distribution snapshot: counts of done/doing/todo
629
+ - wl projects project card view (subtask counts + last activity)
630
+
631
+ Weekly / Linear-update workflow: wl changes --week -> look at changes -> draft the report""")
632
+
633
+ sm = sub.add_parser("summary", parents=[window],
634
+ help="time-window aggregate: done/doing/added counts + grouped by project or day",
635
+ description="Snapshot of current state distribution in a time window: counts of done / doing / added, grouped by project (default) or day. First-pass material for weekly / monthly reports.",
636
+ formatter_class=argparse.RawDescriptionHelpFormatter,
637
+ epilog="""\
638
+ Common examples:
639
+ wl summary --week 2026-W22 # this week (by project)
640
+ wl summary --month 2026-05 # full month
641
+ wl summary --since 2026-05-01 --by day # group by day
642
+ wl summary --week 2026-W22 --top 5 # only the 5 most-progressed projects
643
+ wl summary --week 2026-W22 --projects-only # project rows only, no task expansion
644
+ wl summary --week ... -q # AI context-grab default brief (large token savings)
645
+
646
+ Differences from related commands:
647
+ - wl summary state snapshot (counts of done / doing / todo)
648
+ - wl changes change view (added / closed / log count)
649
+ - wl day full single-day view (with log body)
650
+
651
+ Dedup: by default a task appearing in multiple projects (via parent + shared tag) is listed only once. --no-dedup restores the old behavior.""")
652
+ sm.add_argument("--by", choices=["project", "day"], default="project", help="aggregate dimension (default: project)")
653
+ sm.add_argument("--projects-only", action="store_true",
654
+ help="project rows only, no task expansion (same as --brief but explicit)")
655
+ sm.add_argument("--top", type=int, metavar="N",
656
+ help="only the top N most-progressed projects")
657
+ sm.add_argument("--no-dedup", action="store_true",
658
+ help="no dedup: a task across multiple projects is repeated in each bucket (old behavior)")
659
+
660
+ dy = sub.add_parser("day",
661
+ help="full view of a day (default today): bucket -> project/plan -> task -> log",
662
+ description="Full view of one day: work/personal/other -> (planned/unplanned/project/priority) -> task -> indented logs. Top shows end-of-day summary + today's goal + Top5 (if set). Defaults to log-date-driven (works for past days too).",
663
+ formatter_class=argparse.RawDescriptionHelpFormatter,
664
+ epilog="""\
665
+ Common examples:
666
+ wl day # today
667
+ wl day 2026-05-30 # historical day
668
+ wl day yesterday # short form (yesterday / day-before-yesterday / tomorrow / day-after-tomorrow)
669
+ wl day --by project # change grouping (default plan: planned/unplanned)
670
+ wl day --by priority # group by P0/P1/P2
671
+ wl day --log-tail 1 # logs default to last 3, narrow to 1
672
+ wl day --all-logs # full log expansion (default is last 3)
673
+ wl day --no-logs # don't expand logs, just tasks
674
+ wl day --log-format full # don't truncate body
675
+
676
+ Differences from related commands:
677
+ - wl day single-day overview (plan + actual + status mix, including not-done items)
678
+ - wl active tasks running right now (live focus, no history)
679
+ - wl logs --date YYYY-MM-DD flat log stream for that day (no task structure)
680
+ - wl tree --root <day_id> subtree of that day node (uses tree structure)
681
+
682
+ End-of-day workflow: wl day -> review the day -> wl recap "..." to write the summary.""")
683
+ dy.add_argument("date", nargs="?", help="YYYY-MM-DD (default: today)")
684
+ dy.add_argument("--by", choices=["plan", "project", "priority"], default="plan",
685
+ help="secondary grouping dimension (default: plan = planned/unplanned)")
686
+ dy.add_argument("--depth", type=int, help="(reserved, currently unused)")
687
+ dy.add_argument("--no-logs", action="store_true",
688
+ help="don't expand any log body (same as --brief)")
689
+ dy.add_argument("--log-tail", type=int, metavar="N",
690
+ help="expand at most the latest N logs per task (default 3, middle elided)")
691
+ dy.add_argument("--all-logs", action="store_true",
692
+ help="full log expansion, no elision (overrides default tail=3)")
693
+
694
+ g = sub.add_parser("goal",
695
+ help="read/write today's goal (auto-creates day node + prop 'goal')",
696
+ formatter_class=argparse.RawDescriptionHelpFormatter,
697
+ epilog="""\
698
+ Common examples:
699
+ wl goal "deliver X today" # write
700
+ wl goal # read (no text)
701
+
702
+ wl day shows a top blockquote with the goal. Use at the end of morning planning. Pair with wl recap for end-of-day summary.""")
703
+ g.add_argument("text", nargs="?", help="no arg = read today's goal; with text = write")
704
+
705
+ rc = sub.add_parser("recap",
706
+ help="read/write today's end-of-day summary (auto-stamps summary_at)",
707
+ formatter_class=argparse.RawDescriptionHelpFormatter,
708
+ epilog="""\
709
+ Common examples:
710
+ wl recap "three things today: ..." # write + auto-stamp summary_at
711
+ wl recap # read
712
+
713
+ wl day shows "Recap: ... (written at MM-DD HH:MM)" at the top;
714
+ if there are new non-CLOCK logs after recap, wl day shows "⚠ N changes after recap, consider rewriting".
715
+ Using wl set <day_id> summary "..." directly does not stamp the timestamp; not recommended.""")
716
+ rc.add_argument("text", nargs="?", help="no arg = read; with text = write")
717
+
718
+ tk = sub.add_parser("tick",
719
+ help="quick check-in: add a log to each node today (batch habit check-in)",
720
+ formatter_class=argparse.RawDescriptionHelpFormatter,
721
+ epilog="""\
722
+ Common examples:
723
+ wl tick 39 # default body "✓ done"
724
+ wl tick 39 --note "6 pull-ups" # custom note
725
+ wl tick 39 40 41 # batch check-in multiple habits
726
+ wl tick 218 --done # also mark DONE (one-off task)
727
+
728
+ Difference from wl log: tick defaults to '✓ done' body, great for one-key habit check-in; log needs explicit content.
729
+ For interactive habit batch review, use wl checkin (interactive multi-select).""")
730
+ tk.add_argument("ids", type=int, nargs="+", help="node id(s)")
731
+ tk.add_argument("--note", help="custom log body (default '✓ done')")
732
+ tk.add_argument("--done", action="store_true", help="also mark DONE")
733
+
734
+ def _log_id_arg(s):
735
+ # accepts '#L282' / 'L282' / '282' (wl show / wl logs displays as #L<id>)
736
+ t = s.lstrip("#")
737
+ return int(t[1:] if t.lower().startswith("l") else t)
738
+
739
+ ul = sub.add_parser("unlog",
740
+ help="delete a log entry: #L<id> exact / --node delete latest that day (undo tick)",
741
+ formatter_class=argparse.RawDescriptionHelpFormatter,
742
+ epilog="""\
743
+ Common examples:
744
+ wl unlog #L282 # exact delete by log id
745
+ wl unlog L282 # same (# optional)
746
+ wl unlog 282 # same (plain number)
747
+ wl unlog --node 39 # delete the latest non-CLOCK log for #39 today
748
+ wl unlog --node 39 --date yesterday # latest log that day
749
+ wl unlog --node 39 --all # delete all non-CLOCK logs for #39 that day
750
+
751
+ Find a log id: wl show <node_id> or wl logs --id <node_id> displays #L<id> in the timeline.
752
+ CLOCK_IN/OUT logs cannot be deleted (would break timing pairs). Edit a mistyped log with wl relog #L<id> instead.""")
753
+ ul.add_argument("log_id", type=_log_id_arg, nargs="?",
754
+ help="log id (e.g. #L282 / L282 / 282; from wl show / wl logs timeline)")
755
+ ul.add_argument("--node", type=int, help="delete by node id (default: latest non-CLOCK log today)")
756
+ ul.add_argument("--date", help="with --node: delete logs from that day (default today)")
757
+ ul.add_argument("--all", action="store_true", help="with --node: delete all non-CLOCK logs for that node that day")
758
+
759
+ rl = sub.add_parser("relog",
760
+ help="rewrite a log: new body / new time / editor (CLOCK not accepted)",
761
+ formatter_class=argparse.RawDescriptionHelpFormatter,
762
+ epilog="""\
763
+ Common examples:
764
+ wl relog #L282 "fixed content" # change body
765
+ wl relog #L282 -m "fixed content" # -m mutually exclusive with positional
766
+ wl relog #L282 --at 14:30 # only change time (keep date)
767
+ wl relog #L282 --at 2026-05-30 # only change date (keep time)
768
+ wl relog #L282 # no body/--at -> open $EDITOR
769
+
770
+ CLOCK_IN/OUT logs cannot be edited (would break timing pairs); use wl stop --at to fix CLOCK times.
771
+ Cannot move a log across nodes (that's unlog + log).""")
772
+ rl.add_argument("log_id", type=_log_id_arg,
773
+ help="log id (#L282 / L282 / 282; from wl show / wl logs)")
774
+ rl.add_argument("body", nargs="*", help="new body (positional; no arg -> -m / --at / EDITOR)")
775
+ rl.add_argument("-m", "--message", help="new body (mutually exclusive with positional body; explicit)")
776
+ rl.add_argument("--at", help="change time: HH:MM (keep date) / YYYY-MM-DD / YYYY-MM-DD HH:MM[:SS]")
777
+
778
+ ci = sub.add_parser("checkin",
779
+ help="interactive check-in of today's habits (default multi-select arrows / space / Enter)",
780
+ formatter_class=argparse.RawDescriptionHelpFormatter,
781
+ epilog="""\
782
+ Common examples:
783
+ wl checkin # default multi-select (arrows / space / Enter)
784
+ wl checkin --per-item # fallback: prompt y/n/note/q per item (allows per-item note)
785
+ wl checkin --all-kinds # not just habit; include all task/meetlog/... scheduled today
786
+
787
+ End-of-day: run wl checkin once to review every habit that's due today.
788
+ For single habit check-in, use wl tick <id>.""")
789
+ ci.add_argument("--kind", help="filter by kind (default: habit; use --all-kinds to see anything scheduled)")
790
+ ci.add_argument("--all-kinds", action="store_true",
791
+ help="no kind filter: habit/task/meetlog all listed (including everything scheduled today)")
792
+ ci.add_argument("--per-item", action="store_true",
793
+ help="fallback mode: prompt y/n/note/q per item (allows per-item note; auto-used when not on a TTY)")
794
+
795
+ sc = sub.add_parser("sched",
796
+ help="forward planning: schedule a task to a day / recurring rule (drives wl day 'planned')",
797
+ formatter_class=argparse.RawDescriptionHelpFormatter,
798
+ epilog="""\
799
+ Schedule to a specific day:
800
+ wl sched 42 2026-06-15 # exact date
801
+ wl sched 42 tomorrow # short form (today / yesterday / tomorrow / day-after-tomorrow)
802
+
803
+ Recurring rules (--recur); each supports -1 = last day of the cycle:
804
+ wl sched 42 --recur daily # every day
805
+ wl sched 42 --recur weekly:Mon,Wed,Fri # also numeric weekly:1,3,5 / -1=Sun
806
+ wl sched 42 --recur monthly:5,15,-1 # day 5/15/last each month
807
+ wl sched 42 --recur quarterly:1-15 # 15th of the first month in each quarter
808
+ wl sched 42 --recur quarterly:-1 # last day of each quarter (3/31, 6/30, ...)
809
+ wl sched 42 --recur yearly:03-21 # March 21 every year
810
+ wl sched 42 --recur yearly:-1 # last day of year (12-31)
811
+
812
+ Clear:
813
+ wl sched 42 --clear # clear all schedule entries for this task
814
+
815
+ Difference from wl defer: sched writes to the sched table (precise; appears as "planned" in wl day); defer = status=LATER + rough hint.
816
+ Create + schedule in one line: wl add "..." --sched today""")
817
+ sc.add_argument("id", type=int)
818
+ sc.add_argument("when", nargs="?", help="YYYY-MM-DD / today / yesterday / tomorrow / day-after-tomorrow (one-off date)")
819
+ sc.add_argument("--recur",
820
+ help="recurring rule (all support -1 = last day): daily / weekly:Mon|1-7|-1 / monthly:5|-1 / quarterly:M-D|-1 / yearly:MM-DD|-1")
821
+ sc.add_argument("--clear", action="store_true", help="clear all schedule entries for this task")
822
+
823
+ di = sub.add_parser("dateinfo",
824
+ help="date metadata (holiday/vacation/working-day swap; shown in wl day header)",
825
+ formatter_class=argparse.RawDescriptionHelpFormatter,
826
+ epilog="""\
827
+ Common examples:
828
+ wl dateinfo 2026-05-01 "Labor Day" # single entry
829
+ wl dateinfo 2026-05-03 "swap working day" # working day swap
830
+ wl dateinfo --import holidays.json # batch {"YYYY-MM-DD":"label"}
831
+ wl dateinfo 2026-05-01 --clear # clear
832
+
833
+ wl day shows "<date> <weekday> · <label>" at the top. Weekday comes from the date; dateinfo only stores the extra label.""")
834
+ di.add_argument("date", nargs="?", help="YYYY-MM-DD")
835
+ di.add_argument("label", nargs="?", help="label, e.g. Labor Day / swap working day / vacation")
836
+ di.add_argument("--import", dest="import_file", metavar="FILE", help='batch import {"YYYY-MM-DD":"label"} JSON, - reads stdin')
837
+ di.add_argument("--clear", action="store_true", help="clear the label for this date")
838
+
839
+ im = sub.add_parser("import",
840
+ help="bulk load from JSON ({add:[...],update:[...]}; main AI integration path)",
841
+ formatter_class=argparse.RawDescriptionHelpFormatter,
842
+ epilog="""\
843
+ JSON format (single document):
844
+ {
845
+ "add": [
846
+ {"ref":"p","title":"project name","kind":"project","priority":"A","tags":["work"],
847
+ "children":[{"title":"subtask","kind":"task","priority":"A","status":"DONE","logs":["..."]}]},
848
+ {"title":"another task","kind":"task","parent_ref":"p"}
849
+ ],
850
+ "update": [{"id":42,"status":"DONE","add_tags":["urgent"]}]
851
+ }
852
+
853
+ Common:
854
+ wl import data.json # load
855
+ wl import data.json --dry-run # preview without writing
856
+
857
+ For AI to load a day's worklog / multiple nodes, use this rather than many wl add calls. wl apply is the other option (lightweight wl-diff format).""")
858
+ im.add_argument("file", help="JSON file path, or - for stdin")
859
+ im.add_argument("--dry-run", action="store_true", help="preview without writing")
860
+
861
+ ap = sub.add_parser("apply",
862
+ help="apply wl-diff lightweight bulk changes (+add/~update/-delete/ anchor; same format as wl output)",
863
+ formatter_class=argparse.RawDescriptionHelpFormatter,
864
+ epilog="""\
865
+ wl-diff format:
866
+ #6 [day] 2026-05-29 <- anchor: identifies an existing node as parent, not modified
867
+ + [x] [#A] morning check :planned: <- add (indent = child), [x]=DONE
868
+ + @log monitoring note <- log child
869
+ ~ [x] #14 <- change status of #14 (single-line shorthand)
870
+ ~ #20 <- complex update: lock + field operations
871
+ +tag urgent <- sub-op: add tag
872
+ -log unwanted log <- sub-op: remove
873
+ - #99 <- delete (with subtree)
874
+
875
+ Common:
876
+ wl apply diff.txt # apply
877
+ wl apply diff.txt --dry-run # preview
878
+
879
+ Difference from wl import: import = JSON rich format (good for scripted generation); apply = wl-diff text format (good for small hand-written edits / AI deltas).""")
880
+ ap.add_argument("file", help="wl-diff file path, or - for stdin")
881
+ ap.add_argument("--dry-run", action="store_true", help="validate + preview without writing")
882
+
883
+ fd = sub.add_parser("find",
884
+ help="full-text search nodes (title/body/log/tag/prop/link, any match)",
885
+ description="Full-text search across fields: title/body/log/tag/prop/link, any match returns. Default limit 20; --all removes it.",
886
+ formatter_class=argparse.RawDescriptionHelpFormatter,
887
+ epilog="""\
888
+ Common examples:
889
+ wl find skill # default limit 20
890
+ wl find skill --limit 5 # only the first 5
891
+ wl find skill --all # no limit
892
+ wl find skill --kind project # only projects
893
+ wl find skill --in title,tag # only in title/tag (default: all fields)
894
+
895
+ Differences from related commands:
896
+ - wl find <q> content search (across fields; common 'I remember mentioning X')
897
+ - wl ls --tag X precise tag filter (when you know the dimension)
898
+ - wl ls --recent N by time (recently active)
899
+
900
+ Before writing a new task / log, run wl find to check if there's an existing node to merge into, to avoid duplicates.""")
901
+ fd.add_argument("query")
902
+ fd.add_argument("--in", dest="in_", help="comma-separated fields to search (default: all)")
903
+ fd.add_argument("--kind", help="filter by kind")
904
+ fd.add_argument("--limit", type=int, metavar="N", help="show only the first N (default 20; use 0 or --all for no cap)")
905
+ fd.add_argument("--all", action="store_true", help="no row limit")
906
+
907
+ lg = sub.add_parser("logs", parents=[window],
908
+ help="list log entries (default last 7 days; preset today/yesterday/week/recent)",
909
+ formatter_class=argparse.RawDescriptionHelpFormatter,
910
+ epilog="""\
911
+ Common examples:
912
+ wl logs today # preset: today
913
+ wl logs yesterday # yesterday
914
+ wl logs week # since Monday this week
915
+ wl logs recent # --days 1 + -q
916
+ wl logs --id 42 # all logs for a task
917
+ wl logs --id 42 --tail 5 # last 5 logs for a task
918
+ wl logs --since 2026-05-01 # time window
919
+ wl logs --by-task --tail 3 # aggregate by task, last 3 per task
920
+ wl logs --group day --by project # group by day -> project -> task
921
+
922
+ Differences from related commands:
923
+ - wl logs flat log stream (with task title, one line per log)
924
+ - wl day structured single-day view (plan + task tree + logs)
925
+ - wl show <id> single-node detail + timeline
926
+
927
+ Default window of 7 days avoids full-history flooding. Use --since/--until/--week/--month for precise windows.""")
928
+ lg.add_argument("preset", nargs="?",
929
+ choices=["today", "yesterday", "week", "recent"],
930
+ help="quick preset: today/yesterday (= --date short form) / week (since Monday) / recent (--days 1 -q)")
931
+ lg.add_argument("--id", type=int)
932
+ lg.add_argument("--date", help="YYYY-MM-DD / today / yesterday / day-before-yesterday (only this day)")
933
+ lg.add_argument("--days", type=int, default=7, help="default window in days when no since/date (default: 7)")
934
+ lg.add_argument("--group", choices=["none", "day"], default="none",
935
+ help="day = group by date -> bucket -> task -> log (indented)")
936
+ lg.add_argument("--by", choices=["project", "priority", "plan"], default="project",
937
+ help="secondary grouping dimension under --group day (default: project)")
938
+ lg.add_argument("--no-body", action="store_true",
939
+ help="only [date] #id title, no body (same as --brief)")
940
+ lg.add_argument("--by-task", action="store_true",
941
+ help="aggregate by task (pairs with --tail to get last N per task)")
942
+ lg.add_argument("--tail", type=int, metavar="N",
943
+ help="last N logs per task (pairs with --by-task / --group day; default 3, middle elided)")
944
+ lg.add_argument("--all-logs", action="store_true",
945
+ help="full log expansion, no tail truncation (overrides default tail=3)")
946
+ lg.add_argument("--limit", type=int, metavar="N",
947
+ help="show only the first N logs (for non --by-task cases, to prevent flooding)")
948
+
949
+ sub.add_parser("themes",
950
+ help="list all color themes (one-line preview per theme)",
951
+ formatter_class=argparse.RawDescriptionHelpFormatter,
952
+ epilog="Switch theme: top-level --theme {auto,dark,light,mono} flag, or export WORKLOG_THEME=...; auto probes terminal background and picks dark/light.")
953
+
954
+ pc = sub.add_parser("print-completion",
955
+ help="dump shell completion script (argparse -> fish/bash/zsh; init-load model)",
956
+ formatter_class=argparse.RawDescriptionHelpFormatter,
957
+ epilog="""\
958
+ Usage (write once to your shell rc, then new shells auto-load; stays in sync with code changes):
959
+ # fish: add to ~/.config/fish/config.fish
960
+ wl print-completion fish | source
961
+
962
+ # bash: add to ~/.bashrc
963
+ eval "$(wl print-completion bash)"
964
+
965
+ # zsh: add to ~/.zshrc
966
+ eval "$(wl print-completion zsh)"
967
+
968
+ Same pattern as starship/direnv/zoxide.
969
+
970
+ User aliases: add [aliases] section to ~/.config/worklog/aliases.ini (e.g. d = day / c = checkin / ...); new shells pick them up (uniform across shells).""")
971
+ pc.add_argument("shell", choices=["fish", "bash", "zsh"], help="target shell")
972
+
973
+ return p
974
+
975
+
976
+ from . import commands
977
+ from .commands import (
978
+ cmd_migrate,
979
+ cmd_init,
980
+ cmd_config,
981
+ cmd_add,
982
+ cmd_log,
983
+ _ids_list,
984
+ cmd_done,
985
+ cmd_defer,
986
+ cmd_start,
987
+ cmd_stop,
988
+ cmd_spent,
989
+ cmd_link,
990
+ cmd_set,
991
+ cmd_active,
992
+ cmd_wait,
993
+ cmd_reopen,
994
+ cmd_cancel,
995
+ cmd_show,
996
+ _show_one,
997
+ cmd_ls,
998
+ cmd_projects,
999
+ _tree_by,
1000
+ cmd_tree,
1001
+ cmd_focus,
1002
+ cmd_ancestors,
1003
+ cmd_descendants,
1004
+ _tree_children,
1005
+ _print_day_activity,
1006
+ _print_default_tree,
1007
+ _print_tree,
1008
+ _cn_weekday,
1009
+ _date_label,
1010
+ _sched_fires,
1011
+ _scheduled_node_ids,
1012
+ _sec_sort_key,
1013
+ _render_day_group,
1014
+ cmd_day,
1015
+ _ensure_today_day,
1016
+ _set_prop,
1017
+ _get_prop,
1018
+ cmd_goal,
1019
+ cmd_summary_prop,
1020
+ _checkin_collect,
1021
+ _is_interactive_tty,
1022
+ _multi_select_tty,
1023
+ _checkin_per_item,
1024
+ cmd_checkin,
1025
+ cmd_unlog,
1026
+ cmd_relog,
1027
+ _edit_in_editor,
1028
+ cmd_tick,
1029
+ _norm_rrule,
1030
+ cmd_sched,
1031
+ cmd_dateinfo,
1032
+ cmd_changes,
1033
+ _bulk_status_change,
1034
+ cmd_summary,
1035
+ _import_node,
1036
+ _import_update,
1037
+ cmd_import,
1038
+ _parse_node_line,
1039
+ _parse_fieldop,
1040
+ _parse_wld,
1041
+ _validate_fieldop,
1042
+ _exec_update,
1043
+ _fieldop_desc,
1044
+ cmd_apply,
1045
+ _apply_sub,
1046
+ cmd_find,
1047
+ cmd_logs,
1048
+ cmd_themes,
1049
+ )
1050
+
1051
+ HANDLERS = {
1052
+ "config": cmd_config,
1053
+ "migrate": cmd_migrate,
1054
+ "init": cmd_init,
1055
+ "add": cmd_add,
1056
+ "log": cmd_log,
1057
+ "done": cmd_done,
1058
+ "defer": cmd_defer,
1059
+ "start": cmd_start,
1060
+ "stop": cmd_stop,
1061
+ "spent": cmd_spent,
1062
+ "active": cmd_active,
1063
+ "wait": cmd_wait,
1064
+ "reopen": cmd_reopen,
1065
+ "cancel": cmd_cancel,
1066
+ "link": cmd_link,
1067
+ "set": cmd_set,
1068
+ "show": cmd_show,
1069
+ "ls": cmd_ls,
1070
+ "tree": cmd_tree,
1071
+ "projects": cmd_projects,
1072
+ "changes": cmd_changes,
1073
+ "summary": cmd_summary,
1074
+ "focus": cmd_focus,
1075
+ "ancestors": cmd_ancestors,
1076
+ "descendants": cmd_descendants,
1077
+ "day": cmd_day,
1078
+ "goal": cmd_goal,
1079
+ "recap": cmd_summary_prop,
1080
+ "tick": cmd_tick,
1081
+ "unlog": cmd_unlog,
1082
+ "relog": cmd_relog,
1083
+ "checkin": cmd_checkin,
1084
+ "sched": cmd_sched,
1085
+ "dateinfo": cmd_dateinfo,
1086
+ "import": cmd_import,
1087
+ "apply": cmd_apply,
1088
+ "find": cmd_find,
1089
+ "logs": cmd_logs,
1090
+ "themes": cmd_themes,
1091
+ "print-completion": cmd_print_completion,
1092
+ }
1093
+
1094
+
1095
+ def _print_welcome():
1096
+ """Friendly banner shown when `wl` is run with no subcommand.
1097
+ Points users at the most common commands and `wl --help` for the full list."""
1098
+ print(f"wl {__version__} — SQLite-backed worklog")
1099
+ print("A todo.sh-style CLI for time hierarchy, projects, tasks, habits, meetlogs.")
1100
+ print()
1101
+ print("Getting started:")
1102
+ print(' wl init initialize the database')
1103
+ print(' wl add "task title" -k task add a task')
1104
+ print(' wl log <id> "what happened" append a log entry')
1105
+ print(' wl done <id> mark it done')
1106
+ print(' wl ls list open items')
1107
+ print(' wl tree full tree view')
1108
+ print(' wl day today\'s planned + activity')
1109
+ print()
1110
+ print("See `wl --help` for the full command list, or `wl <command> --help` for details.")
1111
+
1112
+
1113
+ def main(): # pragma: no cover -- argparse entry; tests invoke HANDLERS[cmd] directly to bypass
1114
+ parser = build_parser()
1115
+ args = parser.parse_args()
1116
+ if args.cmd is None:
1117
+ _print_welcome()
1118
+ return
1119
+ # resolve alias back to its primary name (e.g. wl d -> day)
1120
+ if args.cmd not in HANDLERS:
1121
+ for target, alist in (_USER_ALIASES or {}).items():
1122
+ if args.cmd in alist:
1123
+ args.cmd = target
1124
+ break
1125
+ # print-completion is a meta command; needs no DB / console
1126
+ if args.cmd == "print-completion":
1127
+ HANDLERS[args.cmd](args, None)
1128
+ return
1129
+ _init_console(args.color, args.theme)
1130
+ # config is read-only and side-effect free — don't create the DB just to print paths
1131
+ if args.cmd == "config":
1132
+ HANDLERS[args.cmd](args, None)
1133
+ return
1134
+ # --- per-invocation DB override (--db flag) ---
1135
+ # Re-evaluate DB_PATH with args so ensure_db / db_connect see the override.
1136
+ global DB_PATH
1137
+ DB_PATH = _resolve_db_path(args)
1138
+ # `wl migrate` is the explicit form of the auto-migration that ensure_db()
1139
+ # otherwise runs first. Calling ensure_db() here would apply the pending
1140
+ # migrations before the handler runs, leaving nothing to do — so for
1141
+ # `migrate` we just open the DB (creating the file if missing) and let
1142
+ # cmd_migrate decide what to apply.
1143
+ if args.cmd == "migrate":
1144
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
1145
+ con = db_connect()
1146
+ try:
1147
+ HANDLERS[args.cmd](args, con)
1148
+ finally:
1149
+ con.close()
1150
+ return
1151
+ ensure_db()
1152
+ con = db_connect()
1153
+ try:
1154
+ HANDLERS[args.cmd](args, con)
1155
+ finally:
1156
+ con.close()
1157
+
1158
+
1159
+ if __name__ == "__main__":
1160
+ main()