lekha-poth-cli 2.2.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.2.0 → lekha_poth_cli-2.3.0}/PKG-INFO +5 -5
  2. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/README.md +4 -4
  3. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/pyproject.toml +1 -1
  4. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/scripts/generate_spec.py +98 -4
  5. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/README.md +17 -10
  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.2.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/admin.md +24 -20
  11. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/auth-account.md +56 -20
  12. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/chats.md +1 -1
  13. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/legacy.md +7 -7
  14. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/library.md +9 -9
  15. lekha_poth_cli-2.3.0/skills/lp-cli/references/manifest.md +89 -0
  16. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/public-content.md +3 -1
  17. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/references/vocab.md +39 -22
  18. lekha_poth_cli-2.3.0/skills/lp-cli/references/workflows.md +183 -0
  19. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/skills/lp-cli/scripts/generate_references.py +66 -15
  20. {lekha_poth_cli-2.2.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.2.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/config.py +62 -4
  25. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/endpoints.json +76 -49
  26. {lekha_poth_cli-2.2.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.2.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/register.py +50 -11
  30. {lekha_poth_cli-2.2.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.2.0 → lekha_poth_cli-2.3.0}/tests/test_errors.py +77 -1
  34. lekha_poth_cli-2.3.0/tests/test_media.py +265 -0
  35. lekha_poth_cli-2.3.0/tests/test_publish.py +373 -0
  36. lekha_poth_cli-2.3.0/tests/test_setup.py +154 -0
  37. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/tests/test_spec_fields.py +161 -0
  38. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/uv.lock +117 -119
  39. lekha_poth_cli-2.2.0/skills/lp-cli/SKILL.md +0 -383
  40. lekha_poth_cli-2.2.0/skills/lp-cli/evals/evals.json +0 -108
  41. lekha_poth_cli-2.2.0/src/lekha_poth_cli/app.py +0 -287
  42. lekha_poth_cli-2.2.0/src/lekha_poth_cli/client.py +0 -149
  43. lekha_poth_cli-2.2.0/src/lekha_poth_cli/media.py +0 -94
  44. lekha_poth_cli-2.2.0/tests/test_config.py +0 -28
  45. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/.gitignore +0 -0
  46. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/scripts/generate_catalog_v2.py +0 -0
  47. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/__main__.py +0 -0
  48. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/src/lekha_poth_cli/output.py +0 -0
  49. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/tests/test_catalog_v2.py +0 -0
  50. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/tests/test_contract_version_parity.py +0 -0
  51. {lekha_poth_cli-2.2.0 → lekha_poth_cli-2.3.0}/tests/test_coverage.py +0 -0
  52. {lekha_poth_cli-2.2.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.2.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,12 +17,12 @@ 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
 
@@ -68,7 +68,7 @@ lp config set --api-base "$LEKHA_POTH_API_BASE" \
68
68
  --site-url "$LEKHA_POTH_SITE_URL" \
69
69
  --api-key lpak_…
70
70
  # hosted production example:
71
- # --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
72
72
  lp config set-defaults --output pretty --default-size 20
73
73
  lp status
74
74
  lp whoami
@@ -1,12 +1,12 @@
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
 
@@ -52,7 +52,7 @@ lp config set --api-base "$LEKHA_POTH_API_BASE" \
52
52
  --site-url "$LEKHA_POTH_SITE_URL" \
53
53
  --api-key lpak_…
54
54
  # hosted production example:
55
- # --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
56
56
  lp config set-defaults --output pretty --default-size 20
57
57
  lp status
58
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.2.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"
@@ -64,6 +64,10 @@ API_V1_PREFIX = "/api/v1"
64
64
  #: Routes that are infrastructure, not API surface.
65
65
  SKIP_PATHS = {"/docs", "/openapi.json", "/redoc", "/metrics"}
66
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
+
67
71
 
68
72
  def _ensure_backend_importable() -> None:
69
73
  src = REPO_ROOT / "backend" / "src"
@@ -133,9 +137,27 @@ def _cli_type(annotation: Any) -> str:
133
137
  if annotation in (dict, Any) or origin is dict:
134
138
  return "json"
135
139
  if origin in (list, set, tuple):
136
- inner = _cli_type(args[0]) if args else "str"
137
- 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"
138
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])
139
161
  # Optional[X] / Union[X, None] / Annotated[X, ...] -> X
140
162
  return _cli_type(args[0])
141
163
  for python_type, name in _SCALARS.items():
@@ -144,6 +166,29 @@ def _cli_type(annotation: Any) -> str:
144
166
  return "str"
145
167
 
146
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
+
147
192
  #: The PATCH "leave alone" marker. A field whose annotation admits it also
148
193
  #: admits ``None``, which means "clear" — so the CLI must be able to send null.
149
194
  _UNSET_SENTINEL = "unset"
@@ -202,6 +247,15 @@ def choices_from_pattern(pattern: str | None) -> list[str] | None:
202
247
  return words
203
248
 
204
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
+
205
259
  def _pattern_of(field_info: Any) -> str | None:
206
260
  """The ``pattern=`` constraint on a field, wherever pydantic/FastAPI put it."""
207
261
  nodes: list[Any] = list(getattr(field_info, "metadata", None) or [])
@@ -283,6 +337,8 @@ def _params(route: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]], lis
283
337
  "required": required,
284
338
  "multiple": cli_type.endswith("_list"),
285
339
  }
340
+ if is_scalar_or_list(annotation):
341
+ entry["scalar_if_single"] = True
286
342
  choices = field_choices(p.field_info)
287
343
  if choices:
288
344
  entry["choices"] = choices
@@ -336,6 +392,11 @@ def _body(route: Any) -> tuple[list[dict[str, Any]], bool]:
336
392
  entry["required"] = True
337
393
  if cli_type.endswith("_list"):
338
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
339
400
  choices = field_choices(info)
340
401
  if choices:
341
402
  entry["choices"] = choices
@@ -343,10 +404,37 @@ def _body(route: Any) -> tuple[list[dict[str, Any]], bool]:
343
404
  # `--cover-asset-id null` must reach the API as JSON null (clear),
344
405
  # which is distinct from omitting the flag (leave alone).
345
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
346
416
  out.append(entry)
347
417
  return out, _is_required(field)
348
418
 
349
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
+
350
438
  def _infer_auth(path: str) -> str:
351
439
  """Only for NEW routes; tracked ones keep whatever was curated."""
352
440
  if path.startswith(("/admin", "/me")):
@@ -444,7 +532,7 @@ def build(legacy: bool) -> list[dict[str, Any]]:
444
532
 
445
533
  entries: list[dict[str, Any]] = []
446
534
  for full_path, route in walk_routes(app):
447
- if full_path in SKIP_PATHS:
535
+ if full_path in SKIP_PATHS or full_path.startswith(SKIP_PREFIXES):
448
536
  continue
449
537
  mount = "v1" if full_path.startswith(API_V1_PREFIX) else "root"
450
538
  path = full_path.removeprefix(API_V1_PREFIX) or "/"
@@ -466,7 +554,7 @@ def build(legacy: bool) -> list[dict[str, Any]]:
466
554
  "path": path,
467
555
  "summary": (route.summary or "").strip(),
468
556
  "deprecated": bool(route.deprecated),
469
- "auth": _infer_auth(path),
557
+ "auth": "session" if _needs_session(route) else _infer_auth(path),
470
558
  "path_params": path_params,
471
559
  "query": query,
472
560
  "headers": headers,
@@ -525,6 +613,12 @@ def merge(
525
613
  kept = dict(entry)
526
614
  for field in ("id", "group", "name", "auth", "stream"):
527
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"]
528
622
  # A curated body is richer than the derived one only when derivation
529
623
  # found nothing at all.
530
624
  if existing.get("body") and not kept["body"]:
@@ -1,7 +1,7 @@
1
1
  # lp-cli — an agent skill for the Lekha Poth CLI
2
2
 
3
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://lekhapoth.com) through
4
+ that teaches an LLM agent to operate [Lekha Poth](https://www.lekhapoth.com) through
5
5
  `lp`, the full-coverage command-line client published on PyPI as
6
6
  [`lekha-poth-cli`](https://pypi.org/project/lekha-poth-cli/).
7
7
 
@@ -43,6 +43,8 @@ lp-cli/
43
43
  │ ├── chats.md # generated — persona chat
44
44
  │ ├── admin.md # generated — every /admin/* command
45
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
46
48
  │ └── vocab.md # hand-written — what each allowed value means
47
49
  ├── scripts/
48
50
  │ └── generate_references.py # rebuilds references/*.md from the CLI's endpoints.json
@@ -52,7 +54,7 @@ lp-cli/
52
54
 
53
55
  ## Keeping the references in step with your CLI
54
56
 
55
- The six generated reference files describe the CLI at contract **2.2.0**. If
57
+ The six generated reference files describe the CLI at contract **2.3.0**. If
56
58
  `lp --version` reports something newer, rebuild them from the spec that ships
57
59
  inside the installed package — no checkout required:
58
60
 
@@ -61,8 +63,9 @@ uv run --no-project --with lekha-poth-cli python3 scripts/generate_references.py
61
63
  uv run --no-project --with lekha-poth-cli python3 scripts/generate_references.py --check # report only
62
64
  ```
63
65
 
64
- `SKILL.md` and `references/vocab.md` are hand-written and are not touched by
65
- the script; where they and `lp <command> --help` disagree, `--help` is current.
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.
66
69
 
67
70
  ## For maintainers (inside the Lekha Poth repository)
68
71
 
@@ -88,11 +91,15 @@ What still needs a human:
88
91
  - **Behaviour that isn't visible from the schema.** Record it in
89
92
  `ENDPOINT_NOTES` / `FIELD_VOCAB_HINTS` in `generate_references.py`, only
90
93
  after reading the backend code.
91
- - **`references/vocab.md` and `SKILL.md`.** Every claim should name the
92
- source file it was checked against.
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`.
93
98
  - **A new route** needs an entry in `NEW_IDENTITY` in `generate_spec.py`; the
94
99
  generator refuses to invent command names.
95
- - **A CLI behaviour change** means bumping `lpcli`'s `__version__` together
96
- with the contract version in `SKILL.md`'s title and §11, the repo's
97
- `agent/SKILL.md` `version` / `cli_package`, and publishing the new version
98
- to PyPI — a user who only has `uv tool upgrade` sees nothing until then.
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.