litman 1.0.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.
Files changed (83) hide show
  1. litman/__init__.py +3 -0
  2. litman/__main__.py +6 -0
  3. litman/cli.py +637 -0
  4. litman/commands/__init__.py +0 -0
  5. litman/commands/_drift.py +366 -0
  6. litman/commands/_registry_first_time.py +61 -0
  7. litman/commands/add.py +569 -0
  8. litman/commands/code.py +883 -0
  9. litman/commands/config.py +108 -0
  10. litman/commands/drop.py +75 -0
  11. litman/commands/export.py +273 -0
  12. litman/commands/health.py +316 -0
  13. litman/commands/init.py +151 -0
  14. litman/commands/install_completion.py +189 -0
  15. litman/commands/install_skill.py +113 -0
  16. litman/commands/link.py +331 -0
  17. litman/commands/list.py +367 -0
  18. litman/commands/modify.py +636 -0
  19. litman/commands/open.py +133 -0
  20. litman/commands/pdf_text.py +104 -0
  21. litman/commands/project.py +589 -0
  22. litman/commands/promote.py +75 -0
  23. litman/commands/read.py +89 -0
  24. litman/commands/refresh.py +97 -0
  25. litman/commands/related.py +130 -0
  26. litman/commands/rename.py +363 -0
  27. litman/commands/revisit.py +91 -0
  28. litman/commands/rm.py +623 -0
  29. litman/commands/search.py +114 -0
  30. litman/commands/setup.py +321 -0
  31. litman/commands/show.py +110 -0
  32. litman/commands/skim.py +68 -0
  33. litman/commands/sync.py +668 -0
  34. litman/commands/taxonomy.py +765 -0
  35. litman/commands/trash.py +403 -0
  36. litman/commands/vault.py +411 -0
  37. litman/core/__init__.py +0 -0
  38. litman/core/atomic.py +659 -0
  39. litman/core/checks.py +2378 -0
  40. litman/core/code.py +1014 -0
  41. litman/core/code_scan.py +126 -0
  42. litman/core/config.py +243 -0
  43. litman/core/confirm.py +85 -0
  44. litman/core/correctors.py +332 -0
  45. litman/core/dates.py +84 -0
  46. litman/core/dedup.py +187 -0
  47. litman/core/document.py +136 -0
  48. litman/core/id.py +297 -0
  49. litman/core/library.py +258 -0
  50. litman/core/locking.py +137 -0
  51. litman/core/notes.py +149 -0
  52. litman/core/paper_lookup.py +216 -0
  53. litman/core/pdf_text.py +70 -0
  54. litman/core/portable_link.py +165 -0
  55. litman/core/project_link.py +448 -0
  56. litman/core/project_refs.py +274 -0
  57. litman/core/query.py +116 -0
  58. litman/core/related.py +149 -0
  59. litman/core/relations.py +54 -0
  60. litman/core/search.py +87 -0
  61. litman/core/seeds.py +127 -0
  62. litman/core/skill.py +226 -0
  63. litman/core/sync.py +650 -0
  64. litman/core/taxonomy.py +189 -0
  65. litman/core/trash.py +736 -0
  66. litman/core/vault_registry.py +501 -0
  67. litman/core/viewer.py +218 -0
  68. litman/core/views.py +329 -0
  69. litman/exceptions.py +163 -0
  70. litman/exporters/__init__.py +8 -0
  71. litman/exporters/bibtex.py +255 -0
  72. litman/importers/__init__.py +21 -0
  73. litman/importers/crossref.py +158 -0
  74. litman/importers/llm.py +248 -0
  75. litman/skills/__init__.py +6 -0
  76. litman/skills/lit-library/SKILL.md +496 -0
  77. litman/skills/lit-reading/SKILL.md +370 -0
  78. litman-1.0.0.dist-info/METADATA +274 -0
  79. litman-1.0.0.dist-info/RECORD +83 -0
  80. litman-1.0.0.dist-info/WHEEL +5 -0
  81. litman-1.0.0.dist-info/entry_points.txt +2 -0
  82. litman-1.0.0.dist-info/licenses/LICENSE +21 -0
  83. litman-1.0.0.dist-info/top_level.txt +1 -0
litman/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """litman — local-first, AI-augmented literature management CLI."""
2
+
3
+ __version__ = "1.0.0"
litman/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m litman` to invoke the CLI."""
2
+
3
+ from litman.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
litman/cli.py ADDED
@@ -0,0 +1,637 @@
1
+ """litman CLI entry point.
2
+
3
+ Defines the root Click group ``cli`` and the entry-point function ``main``
4
+ referenced by ``[project.scripts]`` in ``pyproject.toml``.
5
+
6
+ Subcommands are registered onto ``cli`` via ``cli.add_command`` below.
7
+ ``main`` wraps ``cli`` so that ``LitmanError`` subclasses become friendly
8
+ single-line error messages with exit code 1; other exceptions propagate as
9
+ normal Python tracebacks (they indicate bugs, not user errors).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import click
19
+ from rich.console import Console
20
+
21
+ from litman import __version__
22
+ from litman.commands.add import add_cmd
23
+ from litman.commands.code import code_group
24
+ from litman.commands.config import config_group
25
+ from litman.commands.drop import drop_cmd
26
+ from litman.commands.export import export_cmd
27
+ from litman.commands.health import health_check_cmd
28
+ from litman.commands.init import init_cmd
29
+ from litman.commands.install_completion import install_completion_cmd
30
+ from litman.commands.install_skill import install_skill_cmd
31
+ from litman.commands.link import link_cmd, unlink_cmd
32
+ from litman.commands.list import list_cmd
33
+ from litman.commands.modify import modify_cmd
34
+ from litman.commands.open import open_cmd
35
+ from litman.commands.pdf_text import pdf_text_cmd
36
+ from litman.commands.project import project_group
37
+ from litman.commands.promote import promote_cmd
38
+ from litman.commands.read import read_cmd
39
+ from litman.commands.refresh import refresh_views_cmd
40
+ from litman.commands.related import related_cmd
41
+ from litman.commands.rename import rename_cmd
42
+ from litman.commands.revisit import revisit_cmd
43
+ from litman.commands.rm import rm_cmd
44
+ from litman.commands.search import search_cmd
45
+ from litman.commands.setup import setup_cmd
46
+ from litman.commands.show import show_cmd
47
+ from litman.commands.skim import skim_cmd
48
+ from litman.commands.sync import sync_group
49
+ from litman.commands.taxonomy import taxonomy_group
50
+ from litman.commands.trash import trash_group
51
+ from litman.commands.vault import vault_group
52
+ from litman.exceptions import LitmanError
53
+
54
+ console = Console()
55
+
56
+
57
+ def _timestamp_is_stale(ts: str | None, *, now: Any, stale_days: int) -> bool:
58
+ """Return True if ``ts`` is older than ``stale_days`` days before ``now``.
59
+
60
+ Generic staleness test shared by the post-dispatch nudges (health-check
61
+ ``last_health_check_at`` and sync ``last_push``).
62
+
63
+ Defensive parse (M30 §5 / human decision 3): a None / missing / unparseable
64
+ timestamp counts as STALE so the nudge fires. A legacy *naive* ISO string
65
+ must not crash — we assume UTC for it rather than raising. ``now`` is passed
66
+ in (tz-aware) so callers / tests control the clock.
67
+ """
68
+ from datetime import datetime, timezone
69
+
70
+ if not ts:
71
+ return True
72
+ try:
73
+ parsed = datetime.fromisoformat(ts)
74
+ except (ValueError, TypeError):
75
+ return True
76
+ if parsed.tzinfo is None:
77
+ parsed = parsed.replace(tzinfo=timezone.utc)
78
+ return (now - parsed).total_seconds() > stale_days * 86400
79
+
80
+
81
+ # Workflow-ordered command groups for `lit --help` / `lit help`. Any command
82
+ # not listed here still appears under "Other" (so a newly added command is
83
+ # never silently hidden) — add it to the right section when you add it.
84
+ _COMMAND_SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
85
+ ("Setup & vaults",
86
+ ("setup", "init", "vault", "install-completion", "install-skill", "help")),
87
+ ("Papers",
88
+ ("add", "list", "show", "search", "related", "open", "pdf-text",
89
+ "modify", "rename", "rm")),
90
+ ("Reading status",
91
+ ("read", "skim", "promote", "revisit", "drop")),
92
+ ("Linking & organization",
93
+ ("link", "unlink", "project", "code", "taxonomy")),
94
+ ("Maintenance",
95
+ ("health-check", "refresh-views", "trash", "sync", "export", "config")),
96
+ )
97
+
98
+
99
+ class LitGroup(click.Group):
100
+ """Root group that (1) renders the command list in workflow sections
101
+ instead of one flat alphabetical block (M27), and (2) surfaces vault
102
+ registry drift before dispatching to most subcommands (M28). Falls back
103
+ to an 'Other' section for any command missing from _COMMAND_SECTIONS so
104
+ nothing is silently hidden."""
105
+
106
+ # Subcommands that must NOT trigger the drift prompt:
107
+ # - help / hello: trivial, registry-irrelevant; would feel out of place
108
+ # - None: the user typed `lit` with no subcommand and is about to see
109
+ # the help message; don't ambush them with a registry prompt
110
+ _DRIFT_SKIP: frozenset[str | None] = frozenset({"help", "hello", None})
111
+
112
+ def invoke(self, ctx: click.Context) -> Any:
113
+ # ``ctx.invoked_subcommand`` is only populated inside ``Group.invoke``
114
+ # AFTER our override runs, so we peek at the raw protected args set
115
+ # by ``Group.parse_args`` to know which subcommand is about to run.
116
+ # An empty protected-args list means ``lit`` with no subcommand
117
+ # (e.g. about to render help) — represented as ``None`` in the skip
118
+ # set, matching the convention used by ``ctx.invoked_subcommand``.
119
+ #
120
+ # ``ctx._protected_args`` is a Click-8.x internal carrying the same
121
+ # value, populated by ``parse_args``. Click 9 plans to remove this
122
+ # attribute (deprecation flagged in 8.2), so ``pyproject.toml`` pins
123
+ # ``click<9``. Bumping past 9 requires either moving this hook into
124
+ # the group callback (after ``parse_args`` has populated
125
+ # ``invoked_subcommand``) or switching to a public-API replacement.
126
+ protected = getattr(ctx, "_protected_args", None) or []
127
+ cmd_name: str | None = protected[0] if protected else None
128
+ if cmd_name not in self._DRIFT_SKIP:
129
+ self._run_drift_hook()
130
+ result = super().invoke(ctx)
131
+ # Post-dispatch staleness nudge (M30 Phase 5), dual to the pre-dispatch
132
+ # drift hook. Fires on the normal return path; same skip gate as the
133
+ # drift hook (bare `lit` / `lit --help` / `lit help` / `lit hello` do
134
+ # not nudge). A command that raises SystemExit (Click's normal exit
135
+ # path, e.g. `health-check` exit 1) bypasses this — accepted limitation;
136
+ # the nudge is a passive reminder, not a guarantee on every exit path.
137
+ if cmd_name not in self._DRIFT_SKIP:
138
+ self._emit_staleness_nudge()
139
+ return result
140
+
141
+ def _run_drift_hook(self) -> None:
142
+ """Tier-1 pre-dispatch drift hook (M28; unified detection M30 Phase 2).
143
+
144
+ Runs the ``tier=cheap`` check subset (``cheap_checks()`` — registry /
145
+ config / bounded-stat only, invariant #15) as the single detection
146
+ core, then dispatches each fired category to its corrector:
147
+
148
+ * klass-B-ext cheap issues (registry #4, project #5) → the ``resolve``
149
+ corrector: the bespoke ``[Y/n]`` prompt + repair in ``_drift.py``,
150
+ which preserves the M28 UX (registry prune default Y; project
151
+ non-destructive default; non-TTY → stderr report, no mutate).
152
+ * klass-A cheap issues → ``regen``. None are registered yet in Phase 2;
153
+ the dispatch below is wired so Phase 3's ``index_vs_disk`` lands
154
+ without re-touching the hook.
155
+
156
+ ``cheap_checks()`` is the same detection core that feeds ``health-check``
157
+ (Tier 2) — registry drift uses the same bounded-stat in both paths,
158
+ closing the §1.1 divergence. We resolve the active vault *cheaply* (read
159
+ the registry active entry + bounded-stat it — never ``find_vault``,
160
+ which bare-``stat()``s the active vault dir and would re-introduce the
161
+ hung-mount risk on the hot path). ``check_vault_registry_drift`` ignores
162
+ the vault entirely, so it runs even when no vault resolves. Once a
163
+ category fires, the matching ``resolve`` corrector self-detects which
164
+ entries / paths to repair (``Issue`` records carry only messages, not
165
+ the registry entries / config map the mutation needs); the cheap check
166
+ having fired is the gate.
167
+
168
+ The whole hook is wrapped so a failure (hung mount, FS error inside a
169
+ heal) never crashes the user's actual command — it degrades to a silent
170
+ skip and the cold-path ``lit health-check`` catches the drift later.
171
+ """
172
+ try:
173
+ from litman.core.checks import cheap_checks
174
+
175
+ vault = self._cheap_active_vault()
176
+
177
+ # Single shared bounded-stat budget (spec §7 / verification task 3):
178
+ # gather every path the cheap bounded-stat checks need (registry
179
+ # vault paths + active-vault project map paths) and probe them with
180
+ # ONE _exists_bounded call. The two checks that would otherwise each
181
+ # probe (vault_registry_drift, project_path_exists) get the result
182
+ # threaded in via exists_status. Worst-case hung-mount cost is one
183
+ # 0.5s budget, not two.
184
+ shared_status = self._cheap_shared_exists(vault)
185
+
186
+ # Detection: run every cheap check. Vault-scoped checks need a vault
187
+ # dir; the registry check ignores it. Collect the fired categories.
188
+ issues: list[Any] = []
189
+ for spec in cheap_checks():
190
+ if vault is None and spec.category != "vault_registry_drift":
191
+ continue
192
+ if spec.category in ("vault_registry_drift", "project_path_exists"):
193
+ issues.extend(
194
+ spec.fn(
195
+ vault or Path("/nonexistent"),
196
+ [],
197
+ exists_status=shared_status,
198
+ )
199
+ )
200
+ else:
201
+ issues.extend(spec.fn(vault or Path("/nonexistent"), []))
202
+ categories = {i.category for i in issues}
203
+
204
+ # Correction dispatch, keyed on the unified detection categories.
205
+ # config_unreadable first: a present-but-unparseable lit-config.yaml
206
+ # blinds the config-keyed drift checks below, so surface it once to
207
+ # stderr (invariant #14 / review F27) — consistent with the corrupt-
208
+ # registry line. Not a prompt: nothing here can auto-repair a
209
+ # hand-broken config; the user must fix the YAML.
210
+ if "config_unreadable" in categories:
211
+ Console(stderr=True).print(
212
+ "[red]error:[/] lit-config.yaml is unreadable: "
213
+ "config-dependent drift checks cannot run. Inspect / "
214
+ "repair it (`lit config show`)."
215
+ )
216
+
217
+ # Registry next: it owns the missing-active-vault case, so project
218
+ # drift must run after it (M28 ordering preserved).
219
+ if "vault_registry_drift" in categories:
220
+ from litman.commands._drift import check_and_prompt_registry_drift
221
+
222
+ check_and_prompt_registry_drift()
223
+
224
+ if "project_path_exists" in categories:
225
+ # Project-path drift heals via staged_write + rebuild, which
226
+ # touch the filesystem from inside the pre-dispatch hook. A
227
+ # failure there must never crash the user's actual command —
228
+ # degrade to silent skip and let `lit health-check` catch it.
229
+ from litman.commands._drift import check_and_prompt_project_drift
230
+
231
+ try:
232
+ check_and_prompt_project_drift()
233
+ except Exception:
234
+ pass
235
+
236
+ # klass-A cheap repair: INDEX.json ↔ papers/ vanished ids (#1).
237
+ # MUST stay metadata-free (invariant #15) — never call regen() /
238
+ # list_papers() here. Spec §6: TTY → metadata-free INDEX regen +
239
+ # targeted (or bulk-deferred) wikilink annotate; non-TTY →
240
+ # report-only, no mutate.
241
+ if vault is not None and "index_vs_disk" in categories:
242
+ self._repair_index_vs_disk(vault, issues)
243
+ except Exception:
244
+ pass
245
+
246
+ @staticmethod
247
+ def _repair_index_vs_disk(vault: "Path", issues: list[Any]) -> None:
248
+ """Tier-1 vanished-id repair (spec §6). Metadata-free (invariant #15).
249
+
250
+ Collects the vanished ids (``index_vs_disk`` errors — present in INDEX,
251
+ absent on disk; the un-indexed-dir warnings carry no INDEX entry to drop
252
+ and need Tier-2). Then:
253
+
254
+ * **non-TTY** (agent / automation): report-only to stderr, NO mutation
255
+ (spec §6 — even lossless A-class regen does not auto-mutate without
256
+ consent in automation). The user/agent runs an explicit fix.
257
+ * **TTY, > 5 vanished** (bulk manual delete / restored backup): drop all
258
+ dead ids from INDEX once (metadata-free) and print ONE line deferring
259
+ all wikilink annotation to ``lit health-check``; do NOT per-id
260
+ annotate.
261
+ * **TTY, ≤ 5 vanished**: metadata-free INDEX drop + per-id targeted
262
+ wikilink annotate (``[[id]] (deleted)``, grep-narrowed).
263
+ """
264
+ from litman.commands._drift import _default_tty_probe
265
+ from litman.core.correctors import annotate, regen_index_drop_ids
266
+
267
+ vanished = sorted(
268
+ {
269
+ i.paper_id
270
+ for i in issues
271
+ if i.category == "index_vs_disk"
272
+ and i.severity == "error"
273
+ and i.paper_id
274
+ }
275
+ )
276
+ if not vanished:
277
+ return
278
+
279
+ if not _default_tty_probe():
280
+ # Automation: report-only, never mutate without consent.
281
+ err = Console(stderr=True)
282
+ joined = ", ".join(vanished)
283
+ err.print(
284
+ f"[yellow]warning:[/] INDEX.json lists {len(vanished)} paper(s) "
285
+ f"no longer on disk: {joined}. Run "
286
+ f"[bold]lit health-check --fix[/] to reconcile."
287
+ )
288
+ return
289
+
290
+ # TTY: metadata-free INDEX repair (lossless klass-A regen).
291
+ regen_index_drop_ids(vault, vanished)
292
+
293
+ if len(vanished) > 5:
294
+ # Bulk: defer all wikilink annotation to health-check's single full
295
+ # pass instead of per-id targeted annotate.
296
+ console.print(
297
+ f"[dim]library changed substantially ({len(vanished)} papers "
298
+ f"vanished), run `lit health-check`[/]"
299
+ )
300
+ return
301
+
302
+ # ≤ 5: per-id targeted wikilink annotate.
303
+ annotate(vault, vanished, targeted=True)
304
+
305
+ @staticmethod
306
+ def _cheap_shared_exists(vault: "Path | None") -> dict[str, "bool | None"]:
307
+ """Single bounded-stat over every path the cheap checks need (task 3).
308
+
309
+ Gathers the registry vault paths plus (when an active vault resolves)
310
+ its ``lit-config.yaml`` project map paths — expanded with the SAME
311
+ ``Path(...).expanduser()`` normalization that
312
+ ``check_project_path_exists`` uses for its lookups — then probes them in
313
+ ONE :func:`_exists_bounded` call sharing a single 0.5s budget. Returns
314
+ the ``{path: bool|None}`` map; an empty dict if nothing to probe. Best-
315
+ effort: a registry / config read failure resolves to whatever paths it
316
+ could gather (the individual checks fall back to None = unknown for the
317
+ rest).
318
+ """
319
+ from litman.commands._drift import _exists_bounded
320
+ from litman.core.vault_registry import (
321
+ VaultRegistryError,
322
+ load_registry,
323
+ )
324
+
325
+ paths: set[str] = set()
326
+ try:
327
+ reg = load_registry()
328
+ paths.update(v.path for v in reg.vaults)
329
+ except VaultRegistryError:
330
+ pass
331
+
332
+ if vault is not None:
333
+ try:
334
+ from litman.core.config import load_config
335
+
336
+ config = load_config(vault)
337
+ paths.update(
338
+ str(Path(p).expanduser()) for p in config.projects.values()
339
+ )
340
+ except Exception:
341
+ pass
342
+
343
+ if not paths:
344
+ return {}
345
+ return _exists_bounded(sorted(paths))
346
+
347
+ @staticmethod
348
+ def _cheap_active_vault() -> "Path | None":
349
+ """Resolve the active vault path without ``find_vault``'s bare stat.
350
+
351
+ Reads the registry active entry and bounded-stats it (invariant #15:
352
+ no per-paper metadata; ADR-014: a dropped mount must not hang). Returns
353
+ the path only when it is definitely present AND holds a
354
+ ``lit-config.yaml``; otherwise ``None`` (the registry-drift check owns
355
+ the missing-vault case, and a ``None``/unknown stat is never actioned).
356
+ """
357
+ from litman.commands._drift import _exists_bounded
358
+ from litman.core.vault_registry import (
359
+ VaultRegistryError,
360
+ find_active,
361
+ load_registry,
362
+ )
363
+
364
+ try:
365
+ reg = load_registry()
366
+ except VaultRegistryError:
367
+ return None
368
+ active = find_active(reg)
369
+ if active is None:
370
+ return None
371
+ if _exists_bounded([active.path]).get(active.path) is not True:
372
+ return None
373
+ candidate = Path(active.path)
374
+ if not (candidate / "lit-config.yaml").is_file():
375
+ return None
376
+ return candidate
377
+
378
+ @staticmethod
379
+ def _emit_staleness_nudge() -> None:
380
+ """Post-dispatch staleness nudges (M30 §5).
381
+
382
+ Two independent, condition-driven reminders for the **active
383
+ registered** vault (an unregistered ``--library`` override has no entry
384
+ → no nudge). Each fires when its tracked timestamp is older than its
385
+ threshold, or None / missing / unparseable (treated as stale):
386
+
387
+ * health-check: ``last_health_check_at`` (registry) >
388
+ :data:`HEALTH_CHECK_STALE_DAYS` → "run ``lit health-check``".
389
+ * sync push: ``.litman-sync-state.yaml`` ``last_push`` >
390
+ :data:`SYNC_STALE_DAYS`, AND only when a remote is configured
391
+ (``lit-config.yaml`` ``sync`` set) → "run ``lit sync push``".
392
+
393
+ Both are stateless (recomputed every command, no "already shown" flag),
394
+ so an ignored tip re-appears on the next command and only clears once
395
+ the user runs the action that refreshes the timestamp.
396
+
397
+ Always emits (invariant #5 / ADR-007: the agent is the primary consumer
398
+ and routinely drives ``lit`` non-interactively, so suppressing in
399
+ non-TTY would silence the reminder for the primary consumer). Only the
400
+ *destination* is TTY-gated: TTY → stdout tail; non-TTY → stderr (keeps
401
+ stdout clean for pipes). Non-disableable: no env var / config read can
402
+ suppress it.
403
+
404
+ Reads only the registry plus, for the sync arm, two bounded vault-root
405
+ files (``lit-config.yaml`` + ``.litman-sync-state.yaml``) gated behind
406
+ the hung-mount-safe :meth:`_cheap_active_vault` resolver — never
407
+ per-paper metadata (invariant #15).
408
+
409
+ Wrapped so a failure never crashes the user's command (dual to the
410
+ pre-dispatch drift hook's defensive wrapper).
411
+ """
412
+ try:
413
+ from datetime import datetime, timezone
414
+
415
+ from litman.commands._drift import _default_tty_probe
416
+ from litman.core.checks import (
417
+ HEALTH_CHECK_STALE_DAYS,
418
+ SYNC_STALE_DAYS,
419
+ )
420
+ from litman.core.vault_registry import (
421
+ VaultRegistryError,
422
+ find_active,
423
+ load_registry,
424
+ )
425
+
426
+ try:
427
+ reg = load_registry()
428
+ except VaultRegistryError:
429
+ return
430
+ active = find_active(reg)
431
+ if active is None:
432
+ return
433
+
434
+ now = datetime.now(timezone.utc)
435
+ tty = _default_tty_probe()
436
+
437
+ def _emit(line: str) -> None:
438
+ if tty:
439
+ console.print(line)
440
+ else:
441
+ Console(stderr=True).print(line)
442
+
443
+ # 1. Health-check staleness — registry-only.
444
+ if _timestamp_is_stale(
445
+ active.last_health_check_at,
446
+ now=now,
447
+ stale_days=HEALTH_CHECK_STALE_DAYS,
448
+ ):
449
+ _emit(
450
+ f"[dim]tip: no `lit health-check` in "
451
+ f"{HEALTH_CHECK_STALE_DAYS}+ days. "
452
+ "Run it to catch silent drift.[/dim]"
453
+ )
454
+
455
+ # 2. Sync-push staleness — only when a remote is configured. The
456
+ # _cheap_active_vault resolver has already bounded-stat'd the vault
457
+ # root and confirmed lit-config.yaml exists, so the two small
458
+ # root-file reads below share that hung-mount-safe envelope. A
459
+ # read failure degrades to no nudge (the explicit `lit sync status`
460
+ # / health-check own the real config-corruption finding).
461
+ vault = LitGroup._cheap_active_vault()
462
+ if vault is not None:
463
+ try:
464
+ from litman.core.config import load_config
465
+ from litman.core.sync import read_sync_state
466
+
467
+ if load_config(vault).sync is not None and _timestamp_is_stale(
468
+ read_sync_state(vault).last_push,
469
+ now=now,
470
+ stale_days=SYNC_STALE_DAYS,
471
+ ):
472
+ _emit(
473
+ f"[dim]tip: no `lit sync push` in "
474
+ f"{SYNC_STALE_DAYS}+ days. "
475
+ "Run it to back up your vault to the configured "
476
+ "remote.[/dim]"
477
+ )
478
+ except Exception:
479
+ pass
480
+ except Exception:
481
+ pass
482
+
483
+ def format_commands(
484
+ self, ctx: click.Context, formatter: click.HelpFormatter
485
+ ) -> None:
486
+ visible: dict[str, click.Command] = {}
487
+ for name in self.list_commands(ctx):
488
+ cmd = self.get_command(ctx, name)
489
+ if cmd is None or cmd.hidden:
490
+ continue
491
+ visible[name] = cmd
492
+ if not visible:
493
+ return
494
+ limit = formatter.width - 6 - max(len(n) for n in visible)
495
+
496
+ def rows(names: list[str]) -> list[tuple[str, str]]:
497
+ return [(n, visible[n].get_short_help_str(limit)) for n in names]
498
+
499
+ placed: set[str] = set()
500
+ for title, names in _COMMAND_SECTIONS:
501
+ present = [n for n in names if n in visible]
502
+ if not present:
503
+ continue
504
+ with formatter.section(title):
505
+ formatter.write_dl(rows(present))
506
+ placed.update(present)
507
+
508
+ leftover = [n for n in visible if n not in placed]
509
+ if leftover:
510
+ with formatter.section("Other"):
511
+ formatter.write_dl(rows(leftover))
512
+
513
+
514
+ @click.group(
515
+ cls=LitGroup,
516
+ context_settings={"help_option_names": ["-h", "--help"]},
517
+ )
518
+ @click.version_option(version=__version__, prog_name="lit")
519
+ def cli() -> None:
520
+ """litman — local-first, AI-augmented literature management CLI.
521
+
522
+ Run lit help COMMAND (or lit COMMAND --help) for command-specific
523
+ help, e.g. lit help code add.
524
+ """
525
+
526
+
527
+ cli.add_command(setup_cmd)
528
+ cli.add_command(init_cmd)
529
+ cli.add_command(add_cmd)
530
+ cli.add_command(list_cmd)
531
+ cli.add_command(show_cmd)
532
+ cli.add_command(search_cmd)
533
+ cli.add_command(related_cmd)
534
+ cli.add_command(open_cmd)
535
+ cli.add_command(pdf_text_cmd)
536
+ cli.add_command(refresh_views_cmd)
537
+ cli.add_command(modify_cmd)
538
+ cli.add_command(read_cmd)
539
+ cli.add_command(revisit_cmd)
540
+ cli.add_command(drop_cmd)
541
+ cli.add_command(promote_cmd)
542
+ cli.add_command(skim_cmd)
543
+ cli.add_command(taxonomy_group)
544
+ cli.add_command(project_group)
545
+ cli.add_command(rename_cmd)
546
+ cli.add_command(rm_cmd)
547
+ cli.add_command(trash_group)
548
+ cli.add_command(health_check_cmd)
549
+ cli.add_command(code_group)
550
+ cli.add_command(config_group)
551
+ cli.add_command(install_skill_cmd)
552
+ cli.add_command(install_completion_cmd)
553
+ cli.add_command(link_cmd)
554
+ cli.add_command(unlink_cmd)
555
+ cli.add_command(sync_group)
556
+ cli.add_command(vault_group)
557
+ cli.add_command(export_cmd)
558
+
559
+
560
+ @cli.command(hidden=True)
561
+ def hello() -> None:
562
+ """Sanity-check command. Confirms lit is installed and importable."""
563
+ console.print(
564
+ f"[bold green]litman[/] v{__version__} is installed and importable."
565
+ )
566
+
567
+
568
+ @cli.command("help")
569
+ @click.argument("command_path", nargs=-1)
570
+ @click.pass_context
571
+ def help_cmd(ctx: click.Context, command_path: tuple[str, ...]) -> None:
572
+ """Show help for lit or a specific command.
573
+
574
+ "lit help" prints the top-level command list (same as "lit --help").
575
+ "lit help COMMAND [SUBCOMMAND ...]" prints that command's help (same as
576
+ "lit COMMAND ... --help"), e.g. "lit help code add".
577
+ """
578
+ # ctx.parent is the root `cli` group's context (info_name == "lit").
579
+ root_ctx = ctx.parent
580
+ if not command_path:
581
+ click.echo(cli.get_help(root_ctx))
582
+ return
583
+
584
+ current_cmd: click.Command = cli
585
+ current_ctx = root_ctx
586
+ walked: list[str] = []
587
+ for name in command_path:
588
+ if not isinstance(current_cmd, click.Group):
589
+ raise click.UsageError(
590
+ f"'lit {' '.join(walked)}' takes no subcommands, "
591
+ f"so '{name}' has no help."
592
+ )
593
+ sub = current_cmd.get_command(current_ctx, name)
594
+ if sub is None:
595
+ raise click.UsageError(f"No such command: {' '.join(command_path)!r}")
596
+ current_ctx = click.Context(sub, info_name=name, parent=current_ctx)
597
+ current_cmd = sub
598
+ walked.append(name)
599
+ click.echo(current_cmd.get_help(current_ctx))
600
+
601
+
602
+ def _force_utf8_output() -> None:
603
+ """Make stdout/stderr encode UTF-8 so Rich glyphs survive everywhere.
604
+
605
+ litman prints non-ASCII output (``✓``/``→``/``—``/``•`` and Rich table
606
+ box-drawing characters). On a legacy-codepage console (most commonly a
607
+ Chinese Windows host, where the default is GBK/cp936) the encoder cannot
608
+ represent these and Python raises ``UnicodeEncodeError`` *after* the data
609
+ has already been committed, leaving a scary traceback on every command.
610
+ The failure also surfaces when stdout is piped (e.g. captured by an
611
+ agent), because Python then falls back to the locale encoding instead of
612
+ the terminal's. Reconfiguring both streams to UTF-8 here fixes both paths;
613
+ the already-constructed ``Console`` objects read ``sys.stdout.encoding``
614
+ lazily at print time, so doing this once before ``cli()`` is enough.
615
+ POSIX hosts are UTF-8 by default and unaffected.
616
+ """
617
+ for stream in (sys.stdout, sys.stderr):
618
+ reconfigure = getattr(stream, "reconfigure", None)
619
+ if reconfigure is not None:
620
+ try:
621
+ reconfigure(encoding="utf-8")
622
+ except (ValueError, OSError):
623
+ pass
624
+
625
+
626
+ def main() -> None:
627
+ """Entry point invoked by the ``lit`` console script."""
628
+ _force_utf8_output()
629
+ try:
630
+ cli()
631
+ except LitmanError as e:
632
+ console.print(f"[bold red]error:[/] {e}")
633
+ sys.exit(1)
634
+
635
+
636
+ if __name__ == "__main__":
637
+ main()
File without changes