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.
- litman/__init__.py +3 -0
- litman/__main__.py +6 -0
- litman/cli.py +637 -0
- litman/commands/__init__.py +0 -0
- litman/commands/_drift.py +366 -0
- litman/commands/_registry_first_time.py +61 -0
- litman/commands/add.py +569 -0
- litman/commands/code.py +883 -0
- litman/commands/config.py +108 -0
- litman/commands/drop.py +75 -0
- litman/commands/export.py +273 -0
- litman/commands/health.py +316 -0
- litman/commands/init.py +151 -0
- litman/commands/install_completion.py +189 -0
- litman/commands/install_skill.py +113 -0
- litman/commands/link.py +331 -0
- litman/commands/list.py +367 -0
- litman/commands/modify.py +636 -0
- litman/commands/open.py +133 -0
- litman/commands/pdf_text.py +104 -0
- litman/commands/project.py +589 -0
- litman/commands/promote.py +75 -0
- litman/commands/read.py +89 -0
- litman/commands/refresh.py +97 -0
- litman/commands/related.py +130 -0
- litman/commands/rename.py +363 -0
- litman/commands/revisit.py +91 -0
- litman/commands/rm.py +623 -0
- litman/commands/search.py +114 -0
- litman/commands/setup.py +321 -0
- litman/commands/show.py +110 -0
- litman/commands/skim.py +68 -0
- litman/commands/sync.py +668 -0
- litman/commands/taxonomy.py +765 -0
- litman/commands/trash.py +403 -0
- litman/commands/vault.py +411 -0
- litman/core/__init__.py +0 -0
- litman/core/atomic.py +659 -0
- litman/core/checks.py +2378 -0
- litman/core/code.py +1014 -0
- litman/core/code_scan.py +126 -0
- litman/core/config.py +243 -0
- litman/core/confirm.py +85 -0
- litman/core/correctors.py +332 -0
- litman/core/dates.py +84 -0
- litman/core/dedup.py +187 -0
- litman/core/document.py +136 -0
- litman/core/id.py +297 -0
- litman/core/library.py +258 -0
- litman/core/locking.py +137 -0
- litman/core/notes.py +149 -0
- litman/core/paper_lookup.py +216 -0
- litman/core/pdf_text.py +70 -0
- litman/core/portable_link.py +165 -0
- litman/core/project_link.py +448 -0
- litman/core/project_refs.py +274 -0
- litman/core/query.py +116 -0
- litman/core/related.py +149 -0
- litman/core/relations.py +54 -0
- litman/core/search.py +87 -0
- litman/core/seeds.py +127 -0
- litman/core/skill.py +226 -0
- litman/core/sync.py +650 -0
- litman/core/taxonomy.py +189 -0
- litman/core/trash.py +736 -0
- litman/core/vault_registry.py +501 -0
- litman/core/viewer.py +218 -0
- litman/core/views.py +329 -0
- litman/exceptions.py +163 -0
- litman/exporters/__init__.py +8 -0
- litman/exporters/bibtex.py +255 -0
- litman/importers/__init__.py +21 -0
- litman/importers/crossref.py +158 -0
- litman/importers/llm.py +248 -0
- litman/skills/__init__.py +6 -0
- litman/skills/lit-library/SKILL.md +496 -0
- litman/skills/lit-reading/SKILL.md +370 -0
- litman-1.0.0.dist-info/METADATA +274 -0
- litman-1.0.0.dist-info/RECORD +83 -0
- litman-1.0.0.dist-info/WHEEL +5 -0
- litman-1.0.0.dist-info/entry_points.txt +2 -0
- litman-1.0.0.dist-info/licenses/LICENSE +21 -0
- litman-1.0.0.dist-info/top_level.txt +1 -0
litman/__init__.py
ADDED
litman/__main__.py
ADDED
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
|