lekha-poth-cli 2.1.0__tar.gz → 2.3.0__tar.gz

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 (52) hide show
  1. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/PKG-INFO +36 -10
  2. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/README.md +35 -9
  3. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/pyproject.toml +1 -1
  4. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/scripts/generate_spec.py +110 -7
  5. lekha_poth_cli-2.3.0/skills/lp-cli/README.md +105 -0
  6. lekha_poth_cli-2.3.0/skills/lp-cli/SKILL.md +410 -0
  7. lekha_poth_cli-2.3.0/skills/lp-cli/evals/evals.json +188 -0
  8. lekha_poth_cli-2.3.0/skills/lp-cli/evals/seed_fixtures.sh +93 -0
  9. lekha_poth_cli-2.3.0/skills/lp-cli/evals/trigger-evals.json +82 -0
  10. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/admin.md +73 -69
  11. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/auth-account.md +83 -47
  12. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/chats.md +6 -6
  13. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/legacy.md +25 -25
  14. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/library.md +37 -37
  15. lekha_poth_cli-2.3.0/skills/lp-cli/references/manifest.md +89 -0
  16. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/public-content.md +18 -16
  17. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/vocab.md +49 -26
  18. lekha_poth_cli-2.3.0/skills/lp-cli/references/workflows.md +183 -0
  19. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/scripts/generate_references.py +121 -27
  20. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/__init__.py +1 -1
  21. lekha_poth_cli-2.3.0/src/lekha_poth_cli/app.py +662 -0
  22. lekha_poth_cli-2.3.0/src/lekha_poth_cli/auth_flow.py +296 -0
  23. lekha_poth_cli-2.3.0/src/lekha_poth_cli/client.py +235 -0
  24. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/config.py +62 -4
  25. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/endpoints.json +76 -49
  26. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/errors.py +49 -0
  27. lekha_poth_cli-2.3.0/src/lekha_poth_cli/media.py +313 -0
  28. lekha_poth_cli-2.3.0/src/lekha_poth_cli/publish.py +480 -0
  29. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/register.py +50 -11
  30. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/spec.py +6 -1
  31. lekha_poth_cli-2.3.0/tests/test_auth.py +254 -0
  32. lekha_poth_cli-2.3.0/tests/test_config.py +65 -0
  33. lekha_poth_cli-2.3.0/tests/test_contract_version_parity.py +34 -0
  34. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/tests/test_errors.py +77 -1
  35. lekha_poth_cli-2.3.0/tests/test_media.py +265 -0
  36. lekha_poth_cli-2.3.0/tests/test_publish.py +373 -0
  37. lekha_poth_cli-2.3.0/tests/test_setup.py +154 -0
  38. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/tests/test_spec_fields.py +161 -0
  39. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/uv.lock +117 -119
  40. lekha_poth_cli-2.1.0/skills/lp-cli/SKILL.md +0 -340
  41. lekha_poth_cli-2.1.0/skills/lp-cli/evals/evals.json +0 -108
  42. lekha_poth_cli-2.1.0/src/lekha_poth_cli/app.py +0 -287
  43. lekha_poth_cli-2.1.0/src/lekha_poth_cli/client.py +0 -149
  44. lekha_poth_cli-2.1.0/src/lekha_poth_cli/media.py +0 -94
  45. lekha_poth_cli-2.1.0/tests/test_config.py +0 -28
  46. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/.gitignore +0 -0
  47. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/scripts/generate_catalog_v2.py +0 -0
  48. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/__main__.py +0 -0
  49. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/output.py +0 -0
  50. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/tests/test_catalog_v2.py +0 -0
  51. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/tests/test_coverage.py +0 -0
  52. {lekha_poth_cli-2.1.0 → lekha_poth_cli-2.3.0}/tests/test_references_current.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lekha-poth-cli
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Production Typer CLI covering every Lekha Poth HTTP API endpoint.
5
5
  Author: Hermes Agent
6
6
  License: MIT
@@ -17,23 +17,49 @@ Description-Content-Type: text/markdown
17
17
  # Lekha Poth CLI (`lp`) v2
18
18
 
19
19
  Production Typer + Pydantic client for **every** Lekha Poth HTTP endpoint,
20
- generated from `backend/src/app/api` on `main` (kept in sync — currently
21
- 140 routes as of product v0.16.2 / `6d2c833`; regenerate with
20
+ generated from `backend/src/app/api` on `main` (kept in sync — `lp endpoints`
21
+ for the live count, synced at product v0.20.1 / `bf5ea4e`; regenerate with
22
22
  `generate_spec.py` after any route change, guarded in CI by
23
23
  `backend/tests/unit/test_agent_spec_current.py`).
24
24
 
25
- Replaces the old stdlib `scripts/agent/lp.py` reader CLI.
25
+ It replaced the stdlib `lp.py` / `lp_media.py`, which were removed in #389.
26
26
 
27
27
  ## Install
28
28
 
29
+ Published on PyPI as [`lekha-poth-cli`](https://pypi.org/project/lekha-poth-cli/).
30
+ No checkout needed:
31
+
29
32
  ```bash
30
- uv venv
31
- uv pip install -e ~/.hermes/skills/software-development/lekha-poth/lpcli
32
- # or: uv tool install -e that-path
33
- lp --help
33
+ uv tool install lekha-poth-cli # → $HOME/.local/bin/lp
34
+ uv tool upgrade lekha-poth-cli # later, to pick up new routes and flags
35
+ $HOME/.local/bin/lp --version
34
36
  ```
35
37
 
36
- The wrapper `~/.local/bin/lp` should point at this package.
38
+ On macOS `/usr/bin/lp` is the print spooler and is on `PATH`, so call the
39
+ Lekha Poth CLI by its absolute path (`$HOME/.local/bin/lp`), or use the
40
+ alias entry point `lekha-poth`, which nothing else claims. To try it without
41
+ installing: `uvx --from lekha-poth-cli lp --help`.
42
+
43
+ An **agent skill** that teaches an LLM agent to drive this CLI ships in the
44
+ sdist under `skills/lp-cli/` (and in the repository at
45
+ `scripts/agent/lpcli/skills/lp-cli/`); its `README.md` says where to put it.
46
+
47
+ ### Developing against a checkout
48
+
49
+ ```bash
50
+ cd scripts/agent/lpcli
51
+ uv venv .venv && uv pip install --python .venv/bin/python -e ".[dev]"
52
+ .venv/bin/python -m lekha_poth_cli --version
53
+ # or, as a tool that tracks the working tree: uv tool install -e .
54
+ ```
55
+
56
+ ### Releasing
57
+
58
+ **Actions → Release (lekha-poth-cli) → Run workflow** (`bump` minor by default,
59
+ or an exact `version`). It stamps every copy of the contract version
60
+ (`scripts/ci/stamp-lpcli-version.py`), runs the tests, `uv build`s, commits
61
+ and tags `lpcli-vX.Y.Z` on `main`, then `uv publish`es to PyPI. See
62
+ `docs/CICD.md` → "Releasing the agent CLI".
37
63
 
38
64
  ## Configure (once)
39
65
 
@@ -42,7 +68,7 @@ lp config set --api-base "$LEKHA_POTH_API_BASE" \
42
68
  --site-url "$LEKHA_POTH_SITE_URL" \
43
69
  --api-key lpak_…
44
70
  # hosted production example:
45
- # --api-base https://api.lekhapoth.com/api/v1 --site-url https://lekhapoth.com
71
+ # --api-base https://api.lekhapoth.com/api/v1 --site-url https://www.lekhapoth.com
46
72
  lp config set-defaults --output pretty --default-size 20
47
73
  lp status
48
74
  lp whoami
@@ -1,23 +1,49 @@
1
1
  # Lekha Poth CLI (`lp`) v2
2
2
 
3
3
  Production Typer + Pydantic client for **every** Lekha Poth HTTP endpoint,
4
- generated from `backend/src/app/api` on `main` (kept in sync — currently
5
- 140 routes as of product v0.16.2 / `6d2c833`; regenerate with
4
+ generated from `backend/src/app/api` on `main` (kept in sync — `lp endpoints`
5
+ for the live count, synced at product v0.20.1 / `bf5ea4e`; regenerate with
6
6
  `generate_spec.py` after any route change, guarded in CI by
7
7
  `backend/tests/unit/test_agent_spec_current.py`).
8
8
 
9
- Replaces the old stdlib `scripts/agent/lp.py` reader CLI.
9
+ It replaced the stdlib `lp.py` / `lp_media.py`, which were removed in #389.
10
10
 
11
11
  ## Install
12
12
 
13
+ Published on PyPI as [`lekha-poth-cli`](https://pypi.org/project/lekha-poth-cli/).
14
+ No checkout needed:
15
+
13
16
  ```bash
14
- uv venv
15
- uv pip install -e ~/.hermes/skills/software-development/lekha-poth/lpcli
16
- # or: uv tool install -e that-path
17
- lp --help
17
+ uv tool install lekha-poth-cli # → $HOME/.local/bin/lp
18
+ uv tool upgrade lekha-poth-cli # later, to pick up new routes and flags
19
+ $HOME/.local/bin/lp --version
18
20
  ```
19
21
 
20
- The wrapper `~/.local/bin/lp` should point at this package.
22
+ On macOS `/usr/bin/lp` is the print spooler and is on `PATH`, so call the
23
+ Lekha Poth CLI by its absolute path (`$HOME/.local/bin/lp`), or use the
24
+ alias entry point `lekha-poth`, which nothing else claims. To try it without
25
+ installing: `uvx --from lekha-poth-cli lp --help`.
26
+
27
+ An **agent skill** that teaches an LLM agent to drive this CLI ships in the
28
+ sdist under `skills/lp-cli/` (and in the repository at
29
+ `scripts/agent/lpcli/skills/lp-cli/`); its `README.md` says where to put it.
30
+
31
+ ### Developing against a checkout
32
+
33
+ ```bash
34
+ cd scripts/agent/lpcli
35
+ uv venv .venv && uv pip install --python .venv/bin/python -e ".[dev]"
36
+ .venv/bin/python -m lekha_poth_cli --version
37
+ # or, as a tool that tracks the working tree: uv tool install -e .
38
+ ```
39
+
40
+ ### Releasing
41
+
42
+ **Actions → Release (lekha-poth-cli) → Run workflow** (`bump` minor by default,
43
+ or an exact `version`). It stamps every copy of the contract version
44
+ (`scripts/ci/stamp-lpcli-version.py`), runs the tests, `uv build`s, commits
45
+ and tags `lpcli-vX.Y.Z` on `main`, then `uv publish`es to PyPI. See
46
+ `docs/CICD.md` → "Releasing the agent CLI".
21
47
 
22
48
  ## Configure (once)
23
49
 
@@ -26,7 +52,7 @@ lp config set --api-base "$LEKHA_POTH_API_BASE" \
26
52
  --site-url "$LEKHA_POTH_SITE_URL" \
27
53
  --api-key lpak_…
28
54
  # hosted production example:
29
- # --api-base https://api.lekhapoth.com/api/v1 --site-url https://lekhapoth.com
55
+ # --api-base https://api.lekhapoth.com/api/v1 --site-url https://www.lekhapoth.com
30
56
  lp config set-defaults --output pretty --default-size 20
31
57
  lp status
32
58
  lp whoami
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lekha-poth-cli"
7
- version = "2.1.0"
7
+ version = "2.3.0"
8
8
  description = "Production Typer CLI covering every Lekha Poth HTTP API endpoint."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -55,6 +55,8 @@ import uuid as _uuid
55
55
  from pathlib import Path
56
56
  from typing import Any
57
57
 
58
+ from pydantic import AwareDatetime, FutureDatetime, NaiveDatetime, PastDatetime
59
+
58
60
  REPO_ROOT = Path(__file__).resolve().parents[4]
59
61
  OUT = REPO_ROOT / "scripts" / "agent" / "lpcli" / "src" / "lekha_poth_cli" / "endpoints.json"
60
62
  API_V1_PREFIX = "/api/v1"
@@ -62,6 +64,10 @@ API_V1_PREFIX = "/api/v1"
62
64
  #: Routes that are infrastructure, not API surface.
63
65
  SKIP_PATHS = {"/docs", "/openapi.json", "/redoc", "/metrics"}
64
66
 
67
+ #: Service-to-service routes (#381): the Cloudflare jobs Worker's endpoint.
68
+ #: Not for agents or any user credential, so never catalogued.
69
+ SKIP_PREFIXES = ("/api/v1/internal/",)
70
+
65
71
 
66
72
  def _ensure_backend_importable() -> None:
67
73
  src = REPO_ROOT / "backend" / "src"
@@ -105,6 +111,15 @@ _SCALARS: dict[Any, str] = {
105
111
  bool: "bool",
106
112
  _uuid.UUID: "uuid",
107
113
  _dt.datetime: "datetime",
114
+ # Pydantic's constrained datetimes are distinct classes at runtime, not
115
+ # `Annotated[datetime, ...]`, so nothing above would unwrap them and the
116
+ # field would degrade to `str`. `publishedAt` is `AwareDatetime` — the API
117
+ # refuses a timezone-less value — and the CLI must still call it a
118
+ # datetime.
119
+ AwareDatetime: "datetime",
120
+ NaiveDatetime: "datetime",
121
+ PastDatetime: "datetime",
122
+ FutureDatetime: "datetime",
108
123
  _dt.date: "date",
109
124
  }
110
125
 
@@ -122,9 +137,27 @@ def _cli_type(annotation: Any) -> str:
122
137
  if annotation in (dict, Any) or origin is dict:
123
138
  return "json"
124
139
  if origin in (list, set, tuple):
125
- inner = _cli_type(args[0]) if args else "str"
126
- return f"{inner}_list"
140
+ if not args:
141
+ return "str_list"
142
+ inner = args[0]
143
+ # `list[tuple[str, float]]` (library import history) has its own CLI
144
+ # type; flattening it to a list of strings sent a shape the API refuses.
145
+ if typing.get_origin(inner) is tuple and list(typing.get_args(inner)) == [str, float]:
146
+ return "str_float_pairs"
147
+ # A list of objects, or of lists, has no flag spelling: take it as JSON.
148
+ if _is_model(inner):
149
+ return "json"
150
+ inner_type = _cli_type(inner)
151
+ if inner_type == "json" or inner_type.endswith(("_list", "_pairs")):
152
+ return "json"
153
+ return f"{inner_type}_list"
127
154
  if origin is not None and args:
155
+ # `str | list[str]`: the list arm decides the flag's type, or a repeated
156
+ # flag would collapse to one string (see `is_scalar_or_list`).
157
+ if origin is not typing.Annotated:
158
+ listy = [a for a in args if typing.get_origin(a) in (list, set, tuple)]
159
+ if listy:
160
+ return _cli_type(listy[0])
128
161
  # Optional[X] / Union[X, None] / Annotated[X, ...] -> X
129
162
  return _cli_type(args[0])
130
163
  for python_type, name in _SCALARS.items():
@@ -133,6 +166,29 @@ def _cli_type(annotation: Any) -> str:
133
166
  return "str"
134
167
 
135
168
 
169
+ def _is_model(annotation: Any) -> bool:
170
+ from pydantic import BaseModel
171
+
172
+ return isinstance(annotation, type) and issubclass(annotation, BaseModel)
173
+
174
+
175
+ def is_scalar_or_list(annotation: Any) -> bool:
176
+ """True for ``str | list[str]`` (optionally ``| None``): one value or many.
177
+
178
+ The CLI flag is repeatable. One value goes on the wire as the scalar and
179
+ several as a list, so ``--value my-series`` for ``move-collection`` stays a
180
+ string while ``--value a --value b`` for ``retag`` becomes ``["a", "b"]``.
181
+ """
182
+ import types
183
+ import typing
184
+
185
+ if typing.get_origin(annotation) not in (typing.Union, types.UnionType):
186
+ return False
187
+ args = [a for a in typing.get_args(annotation) if a is not type(None)]
188
+ listy = [a for a in args if typing.get_origin(a) in (list, set, tuple)]
189
+ return bool(listy) and len(listy) < len(args)
190
+
191
+
136
192
  #: The PATCH "leave alone" marker. A field whose annotation admits it also
137
193
  #: admits ``None``, which means "clear" — so the CLI must be able to send null.
138
194
  _UNSET_SENTINEL = "unset"
@@ -145,9 +201,7 @@ def _walk_annotation(annotation: Any) -> list[Any]:
145
201
  """The annotation and every type/metadata object nested inside it."""
146
202
  import typing
147
203
 
148
- seen: list[Any] = [annotation]
149
- for metadata in getattr(annotation, "__metadata__", ()):
150
- seen.append(metadata)
204
+ seen: list[Any] = [annotation, *getattr(annotation, "__metadata__", ())]
151
205
  for arg in typing.get_args(annotation):
152
206
  seen.extend(_walk_annotation(arg))
153
207
  return seen
@@ -193,6 +247,15 @@ def choices_from_pattern(pattern: str | None) -> list[str] | None:
193
247
  return words
194
248
 
195
249
 
250
+ def _max_length(field_info: Any) -> int | None:
251
+ """``Field(max_length=N)`` on a list field: the most values the API accepts."""
252
+ for node in getattr(field_info, "metadata", None) or []:
253
+ value = getattr(node, "max_length", None)
254
+ if isinstance(value, int):
255
+ return value
256
+ return None
257
+
258
+
196
259
  def _pattern_of(field_info: Any) -> str | None:
197
260
  """The ``pattern=`` constraint on a field, wherever pydantic/FastAPI put it."""
198
261
  nodes: list[Any] = list(getattr(field_info, "metadata", None) or [])
@@ -274,6 +337,8 @@ def _params(route: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]], lis
274
337
  "required": required,
275
338
  "multiple": cli_type.endswith("_list"),
276
339
  }
340
+ if is_scalar_or_list(annotation):
341
+ entry["scalar_if_single"] = True
277
342
  choices = field_choices(p.field_info)
278
343
  if choices:
279
344
  entry["choices"] = choices
@@ -327,6 +392,11 @@ def _body(route: Any) -> tuple[list[dict[str, Any]], bool]:
327
392
  entry["required"] = True
328
393
  if cli_type.endswith("_list"):
329
394
  entry["multiple"] = True
395
+ max_items = _max_length(info)
396
+ if max_items is not None:
397
+ entry["max_items"] = max_items
398
+ if is_scalar_or_list(annotation):
399
+ entry["scalar_if_single"] = True
330
400
  choices = field_choices(info)
331
401
  if choices:
332
402
  entry["choices"] = choices
@@ -334,10 +404,37 @@ def _body(route: Any) -> tuple[list[dict[str, Any]], bool]:
334
404
  # `--cover-asset-id null` must reach the API as JSON null (clear),
335
405
  # which is distinct from omitting the flag (leave alone).
336
406
  entry["sentinel_null"] = True
407
+ default = info.default
408
+ if (
409
+ not info.is_required()
410
+ and isinstance(default, (bool, int, float, str))
411
+ and default != _UNSET_SENTINEL
412
+ ):
413
+ # Documentation only: an unset flag is omitted and the API applies
414
+ # this itself. `--include-media` is worth knowing defaults to true.
415
+ entry["default"] = default
337
416
  out.append(entry)
338
417
  return out, _is_required(field)
339
418
 
340
419
 
420
+ def _needs_session(route: Any) -> bool:
421
+ """True when the route depends on ``require_jwt_session`` at any depth.
422
+
423
+ Those routes refuse a personal API key (403) and want the bearer token of
424
+ an interactive login. Derived, never curated: a hand-kept list is how the
425
+ docs ended up telling agents to run ``lp keys permissions`` with a key.
426
+ """
427
+ from app.core.security import require_jwt_session
428
+
429
+ stack = [route.dependant]
430
+ while stack:
431
+ dependant = stack.pop()
432
+ if dependant.call is require_jwt_session:
433
+ return True
434
+ stack.extend(dependant.dependencies)
435
+ return False
436
+
437
+
341
438
  def _infer_auth(path: str) -> str:
342
439
  """Only for NEW routes; tracked ones keep whatever was curated."""
343
440
  if path.startswith(("/admin", "/me")):
@@ -435,7 +532,7 @@ def build(legacy: bool) -> list[dict[str, Any]]:
435
532
 
436
533
  entries: list[dict[str, Any]] = []
437
534
  for full_path, route in walk_routes(app):
438
- if full_path in SKIP_PATHS:
535
+ if full_path in SKIP_PATHS or full_path.startswith(SKIP_PREFIXES):
439
536
  continue
440
537
  mount = "v1" if full_path.startswith(API_V1_PREFIX) else "root"
441
538
  path = full_path.removeprefix(API_V1_PREFIX) or "/"
@@ -457,7 +554,7 @@ def build(legacy: bool) -> list[dict[str, Any]]:
457
554
  "path": path,
458
555
  "summary": (route.summary or "").strip(),
459
556
  "deprecated": bool(route.deprecated),
460
- "auth": _infer_auth(path),
557
+ "auth": "session" if _needs_session(route) else _infer_auth(path),
461
558
  "path_params": path_params,
462
559
  "query": query,
463
560
  "headers": headers,
@@ -516,6 +613,12 @@ def merge(
516
613
  kept = dict(entry)
517
614
  for field in ("id", "group", "name", "auth", "stream"):
518
615
  kept[field] = existing[field]
616
+ # "session" is derived from the route's dependencies, so it overrides a
617
+ # curated value in both directions: gained or lost with the dependency.
618
+ if entry["auth"] == "session":
619
+ kept["auth"] = "session"
620
+ elif existing["auth"] == "session":
621
+ kept["auth"] = entry["auth"]
519
622
  # A curated body is richer than the derived one only when derivation
520
623
  # found nothing at all.
521
624
  if existing.get("body") and not kept["body"]:
@@ -0,0 +1,105 @@
1
+ # lp-cli — an agent skill for the Lekha Poth CLI
2
+
3
+ This directory is a self-contained [agent skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills)
4
+ that teaches an LLM agent to operate [Lekha Poth](https://www.lekhapoth.com) through
5
+ `lp`, the full-coverage command-line client published on PyPI as
6
+ [`lekha-poth-cli`](https://pypi.org/project/lekha-poth-cli/).
7
+
8
+ You do **not** need the Lekha Poth source repository to use it. You need:
9
+
10
+ 1. **`uv`** — `curl -LsSf https://astral.sh/uv/install.sh | sh` (macOS/Linux).
11
+ 2. **The CLI** — `uv tool install lekha-poth-cli`, which puts `lp` at
12
+ `$HOME/.local/bin/lp`. Keep it current with `uv tool upgrade lekha-poth-cli`.
13
+ 3. **This directory**, placed where your agent loads skills from.
14
+
15
+ ## Installing the skill
16
+
17
+ Copy the whole `lp-cli/` directory (keep `SKILL.md`, `references/`, `scripts/`
18
+ together) into your agent's skills location:
19
+
20
+ | Agent | Where |
21
+ |---|---|
22
+ | Claude Code, for one user | `~/.claude/skills/lp-cli/` |
23
+ | Claude Code, for one project | `<project>/.claude/skills/lp-cli/` |
24
+ | Any other agent that reads `SKILL.md` frontmatter | its skills directory |
25
+
26
+ Or package it as a single file with the skill-creator's
27
+ `package_skill.py` and install the resulting `lp-cli.skill`.
28
+
29
+ The agent reads `SKILL.md` first. It holds the install and pre-flight steps,
30
+ task recipes, the exit-code table and the wire conventions, and routes to one
31
+ of the `references/*.md` files for a command's exact flags.
32
+
33
+ ## Layout
34
+
35
+ ```
36
+ lp-cli/
37
+ ├── SKILL.md # entry point: pre-flight, recipes, conventions, errors
38
+ ├── README.md # this file (for humans)
39
+ ├── references/
40
+ │ ├── public-content.md # generated — items, collections, search, tags, sitemap, stats
41
+ │ ├── auth-account.md # generated — auth, /me, API keys
42
+ │ ├── library.md # generated — reader library + reports
43
+ │ ├── chats.md # generated — persona chat
44
+ │ ├── admin.md # generated — every /admin/* command
45
+ │ ├── legacy.md # generated — deprecated /chapters, /series aliases
46
+ │ ├── workflows.md # hand-written — setup, login, media, publish (the non-generated commands)
47
+ │ ├── manifest.md # hand-written — `lp publish manifest` JSON schema
48
+ │ └── vocab.md # hand-written — what each allowed value means
49
+ ├── scripts/
50
+ │ └── generate_references.py # rebuilds references/*.md from the CLI's endpoints.json
51
+ └── evals/
52
+ └── evals.json # test prompts used to evaluate the skill
53
+ ```
54
+
55
+ ## Keeping the references in step with your CLI
56
+
57
+ The six generated reference files describe the CLI at contract **2.3.0**. If
58
+ `lp --version` reports something newer, rebuild them from the spec that ships
59
+ inside the installed package — no checkout required:
60
+
61
+ ```bash
62
+ uv run --no-project --with lekha-poth-cli python3 scripts/generate_references.py # rewrite
63
+ uv run --no-project --with lekha-poth-cli python3 scripts/generate_references.py --check # report only
64
+ ```
65
+
66
+ `SKILL.md`, `references/vocab.md`, `references/workflows.md` and
67
+ `references/manifest.md` are hand-written and are not touched by the script;
68
+ where they and `lp <command> --help` disagree, `--help` is current.
69
+
70
+ ## For maintainers (inside the Lekha Poth repository)
71
+
72
+ The skill lives at `scripts/agent/lpcli/skills/lp-cli/` and CI enforces that
73
+ it matches the API. After any backend route or parameter change, from the
74
+ repository root:
75
+
76
+ ```bash
77
+ cd backend && uv run python ../scripts/agent/lpcli/scripts/generate_spec.py # endpoints.json (types, choices, sentinels)
78
+ cd .. && python3 scripts/agent/lpcli/scripts/generate_catalog_v2.py # docs/agent/API_CATALOG_V2.md
79
+ python3 scripts/agent/lpcli/skills/lp-cli/scripts/generate_references.py # references/*.md
80
+ ```
81
+
82
+ `backend/tests/unit/test_agent_spec_current.py` compares the spec with the
83
+ live app; `scripts/agent/lpcli/tests/test_references_current.py` and
84
+ `test_catalog_v2.py` check the two derived documents. When the skill is run
85
+ from inside the checkout, `generate_references.py` reads the repository's
86
+ `endpoints.json` rather than the installed package's, so the check is against
87
+ the code under review. Allowed values are derived from the backend's
88
+ `Literal` and `pattern=` validators, so a new vocabulary appears on its own.
89
+ What still needs a human:
90
+
91
+ - **Behaviour that isn't visible from the schema.** Record it in
92
+ `ENDPOINT_NOTES` / `FIELD_VOCAB_HINTS` in `generate_references.py`, only
93
+ after reading the backend code.
94
+ - **`SKILL.md`, `references/vocab.md`, `workflows.md`, `manifest.md`.** Every
95
+ claim should name the source file it was checked against. A change to a
96
+ hand-written command (`app.py`, `auth_flow.py`, `media.py`, `publish.py`)
97
+ means updating `workflows.md` / `manifest.md`.
98
+ - **A new route** needs an entry in `NEW_IDENTITY` in `generate_spec.py`; the
99
+ generator refuses to invent command names.
100
+ - **A CLI behaviour change** ships through **Actions → Release
101
+ (lekha-poth-cli)**, which runs `scripts/ci/stamp-lpcli-version.py` to update
102
+ every copy of the contract version (package, this skill's title, §0 and §11,
103
+ this README, `agent/SKILL.md`, `public/.well-known/agent.json`) and
104
+ publishes to PyPI. Don't bump them by hand: a user who only has
105
+ `uv tool upgrade` sees nothing until the release.