levain-cli 0.1.0__tar.gz → 0.1.10__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 (26) hide show
  1. {levain_cli-0.1.0 → levain_cli-0.1.10}/.gitignore +14 -0
  2. {levain_cli-0.1.0 → levain_cli-0.1.10}/PKG-INFO +3 -3
  3. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/cli.py +45 -9
  4. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/codegen.py +51 -0
  5. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/integrations_check.py +14 -0
  6. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/source_check.py +165 -0
  7. {levain_cli-0.1.0 → levain_cli-0.1.10}/pyproject.toml +3 -3
  8. {levain_cli-0.1.0 → levain_cli-0.1.10}/LICENSE +0 -0
  9. {levain_cli-0.1.0 → levain_cli-0.1.10}/README.md +0 -0
  10. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/__init__.py +0 -0
  11. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/__main__.py +0 -0
  12. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/_format.py +0 -0
  13. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/_java.py +0 -0
  14. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/auth.py +0 -0
  15. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/graph.py +0 -0
  16. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/known_recipes.py +0 -0
  17. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/mcp.py +0 -0
  18. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/models.py +0 -0
  19. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/parser.py +0 -0
  20. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/profile.py +0 -0
  21. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/py.typed +0 -0
  22. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/reducers.py +0 -0
  23. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/tlc.py +0 -0
  24. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/translate.py +0 -0
  25. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/user_input.py +0 -0
  26. {levain_cli-0.1.0 → levain_cli-0.1.10}/levain_recipe/validate.py +0 -0
@@ -117,3 +117,17 @@ kubeconfig
117
117
 
118
118
  # Local TLC toolchain drop-in (CI extracts it from the ghcr carrier)
119
119
  tools/tla2tools.jar
120
+
121
+ # Box-substrate state left over from local backends: state carries secrets.
122
+ infra/stage1-box/*.tfstate*
123
+ infra/stage2-box/*.tfstate*
124
+ # Terraform's crash dump of unwritten state — can contain secrets, never commit
125
+ errored.tfstate
126
+
127
+ # Saved plans embed resolved variable values, including secrets.
128
+ *.tfplan
129
+
130
+ # HyperFrames build artifacts — the shipped MP4 and poster live in
131
+ # www/public/videos/; the render and proof frames are reproducible.
132
+ assets/video/*/renders/
133
+ assets/video/*/snapshots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: levain-cli
3
- Version: 0.1.0
3
+ Version: 0.1.10
4
4
  Summary: Levain recipe toolchain: PlusCal validator, TLC model checking, and GraphState codegen for agent recipes
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -9,9 +9,9 @@ Requires-Dist: httpx==0.28.1
9
9
  Requires-Dist: pydantic==2.13.4
10
10
  Requires-Dist: tree-sitter-tlaplus==1.5.0
11
11
  Requires-Dist: tree-sitter==0.26.0
12
- Requires-Dist: typer==0.26.8
12
+ Requires-Dist: typer==0.27.2
13
13
  Provides-Extra: mcp
14
- Requires-Dist: fastmcp==3.4.7; extra == 'mcp'
14
+ Requires-Dist: fastmcp==4.0.5; extra == 'mcp'
15
15
  Description-Content-Type: text/markdown
16
16
 
17
17
  # levain-cli
@@ -22,7 +22,7 @@ import typer
22
22
  from . import LANGUAGE_VERSION
23
23
  from ._format import format_errors, format_success
24
24
  from ._java import ensure_jar
25
- from .codegen import generate_state_module
25
+ from .codegen import generate_state_module, state_module_signature
26
26
  from .validate import validate, validate_fast, validate_package
27
27
 
28
28
  app = typer.Typer(
@@ -125,6 +125,29 @@ def validate_package_cmd(
125
125
  print(format_errors(result))
126
126
  raise typer.Exit(1)
127
127
 
128
+ # ``_state.py`` is generated from the spec; validating a tree
129
+ # whose copy is stale would bless code the graph no longer
130
+ # matches. The comparison is structural (field names, types,
131
+ # reducers) so formatter rewraps and doc metadata don't trip
132
+ # it. Read-only on purpose — ``generate-state`` writes.
133
+ state_path = recipe_dir / "src" / result.module_name / "_state.py"
134
+ if state_path.parent.is_dir():
135
+ expected = state_module_signature(generate_state_module(result))
136
+ current = (
137
+ state_module_signature(state_path.read_text(encoding="utf-8"))
138
+ if state_path.is_file()
139
+ else None
140
+ )
141
+ if current != expected:
142
+ what = "missing" if current is None else "stale"
143
+ print(
144
+ f"Error: {state_path.relative_to(recipe_dir)} is {what} — "
145
+ f"run `levain generate-state {recipe_dir}` and commit "
146
+ f"the result.",
147
+ file=sys.stderr,
148
+ )
149
+ raise typer.Exit(1)
150
+
128
151
  print(format_success(result))
129
152
 
130
153
 
@@ -305,20 +328,21 @@ def git_credential_cmd(
305
328
 
306
329
  # git gives us the repo it wants, so the credential is scoped to
307
330
  # exactly that rather than to the whole workspace.
308
- slug = _slug_from_git_path(fields.get("path", ""))
309
- if not slug:
331
+ repo = _repo_from_git_path(fields.get("path", ""))
332
+ if repo is None:
310
333
  print(
311
334
  "levain git-credential: not a Levain recipe URL",
312
335
  file=sys.stderr,
313
336
  )
314
337
  raise typer.Exit(1)
338
+ scope, slug = repo
315
339
 
316
340
  api = api_base()
317
341
  try:
318
342
  with _client() as client:
319
343
  token = access_token(api, client)
320
344
  resp = client.post(
321
- f"{api}/api/v1/agents/{slug}/git-credential",
345
+ f"{api}/api/v1/{scope}/{slug}/git-credential",
322
346
  headers={"Authorization": f"Bearer {token}"},
323
347
  )
324
348
  except AuthError as exc:
@@ -336,9 +360,21 @@ def git_credential_cmd(
336
360
  print(f"password={body['password']}")
337
361
 
338
362
 
339
- def _slug_from_git_path(path: str) -> str:
340
- """Agent slug from a recipe repo path (``ws-<hex>/<slug>.git``)."""
363
+ def _repo_from_git_path(path: str) -> tuple[str, str] | None:
364
+ """``(api scope, slug)`` for a recipe repo path, or None.
365
+
366
+ Workspace agents live at ``ws-<hex>/<slug>.git`` and mint under
367
+ ``agents``; org agents at ``org-<hex>/<slug>.git`` under
368
+ ``org/agents``.
369
+ """
341
370
  parts = path.strip("/").split("/")
342
- if len(parts) != 2 or not parts[0].startswith("ws-"):
343
- return ""
344
- return parts[1].removesuffix(".git")
371
+ if len(parts) != 2:
372
+ return None
373
+ owner, repo = parts
374
+ if owner.startswith("ws-"):
375
+ scope = "agents"
376
+ elif owner.startswith("org-"):
377
+ scope = "org/agents"
378
+ else:
379
+ return None
380
+ return scope, repo.removesuffix(".git")
@@ -112,3 +112,54 @@ def _py_type_for(var: StateField) -> str:
112
112
  if desc_literal is not None:
113
113
  return f"Annotated[{py_type.__name__}, {desc_literal}]"
114
114
  return py_type.__name__
115
+
116
+
117
+ def state_module_signature(text: str) -> dict[str, str] | None:
118
+ """Structural signature of a ``_state.py`` module.
119
+
120
+ Maps field name -> normalized annotation, where ``Annotated``
121
+ is reduced to its base type plus any non-string metadata (the
122
+ reducer callables) — docstring metadata and formatting don't
123
+ affect the signature. Lets ``validate-package`` compare an
124
+ on-disk ``_state.py`` against what codegen would emit without
125
+ tripping on ``ruff format`` rewraps. Returns ``None`` when the
126
+ text isn't parseable or has no ``GraphState`` class.
127
+ """
128
+ import ast as _ast
129
+
130
+ try:
131
+ tree = _ast.parse(text)
132
+ except SyntaxError:
133
+ return None
134
+ for node in _ast.iter_child_nodes(tree):
135
+ if isinstance(node, _ast.ClassDef) and node.name == "GraphState":
136
+ fields: dict[str, str] = {}
137
+ for stmt in node.body:
138
+ if not isinstance(stmt, _ast.AnnAssign) or not isinstance(
139
+ stmt.target, _ast.Name
140
+ ):
141
+ continue
142
+ fields[stmt.target.id] = _normalize_annotation(stmt.annotation)
143
+ return fields
144
+ return None
145
+
146
+
147
+ def _normalize_annotation(node: object) -> str:
148
+ """Reduce an annotation AST to base type + non-string metadata."""
149
+ import ast as _ast
150
+
151
+ if (
152
+ isinstance(node, _ast.Subscript)
153
+ and isinstance(node.value, _ast.Name)
154
+ and node.value.id == "Annotated"
155
+ and isinstance(node.slice, _ast.Tuple)
156
+ and node.slice.elts
157
+ ):
158
+ base = _ast.dump(node.slice.elts[0])
159
+ reducers = [
160
+ _ast.dump(e)
161
+ for e in node.slice.elts[1:]
162
+ if not (isinstance(e, _ast.Constant) and isinstance(e.value, str))
163
+ ]
164
+ return " | ".join([base, *reducers])
165
+ return _ast.dump(node) if isinstance(node, _ast.AST) else repr(node)
@@ -68,6 +68,20 @@ def platform_server_names() -> set[str]:
68
68
  return set(PLATFORM_MCP_SERVERS)
69
69
 
70
70
 
71
+ # Platform servers named after an integration provider, mirroring
72
+ # ``levain_sdk.mcp_registry._PROVIDER_NAMED_SERVERS``. Vendored for the
73
+ # same reason as ``PLATFORM_MCP_SERVERS`` above; kept equal by the same
74
+ # drift test in ``tests/spec/test_known_recipes.py``.
75
+ PROVIDER_MCP_SERVERS = frozenset(
76
+ {"google_ads", "meta_ads", "shopify", "slack", "stripe"}
77
+ )
78
+
79
+
80
+ def provider_server_names() -> set[str]:
81
+ """Platform server names that double as an integration provider."""
82
+ return set(PROVIDER_MCP_SERVERS)
83
+
84
+
71
85
  def _load_env_targets() -> list[TargetIntegration]:
72
86
  """Attached integrations from the sandbox env, when available.
73
87
 
@@ -36,6 +36,14 @@ def check_python_sources(
36
36
  ``@node``, or be marked private with a leading underscore.
37
37
  Catches recipes that add Python functions for new behaviour
38
38
  but forget to wire them into ``graph.tla``.
39
+ 6. ``load_prompt`` calls agree with their templates — every
40
+ kwarg is referenced as ``$var``/``${var}`` in the prompt
41
+ file (``{var}`` is str.format syntax and would raise at
42
+ run time), and the prompt file exists.
43
+ 7. ``levain_sdk.claude`` calls that mean "no tools" say
44
+ ``tools=[]`` — an empty ``allowed_tools`` is only a
45
+ permission allowlist and does not withhold the built-in
46
+ toolset.
39
47
  """
40
48
  errors: list[ValidationError] = []
41
49
  src_dir = os.path.join(recipe_dir, "src")
@@ -43,6 +51,8 @@ def check_python_sources(
43
51
  errors.extend(_check_pyproject(recipe_dir))
44
52
  errors.extend(_check_function_refs(spec.nodes, src_dir))
45
53
  errors.extend(_check_orphan_node_functions(spec.nodes, src_dir))
54
+ errors.extend(_check_prompt_templates(src_dir))
55
+ errors.extend(_check_claude_tool_guards(src_dir))
46
56
 
47
57
  return errors
48
58
 
@@ -277,3 +287,158 @@ def _check_orphan_node_functions(
277
287
  )
278
288
 
279
289
  return errors
290
+
291
+
292
+ def _iter_source_files(src_dir: str) -> list[Path]:
293
+ """Every ``.py`` file under ``src/`` except generated ``_state.py``."""
294
+ root = Path(src_dir)
295
+ if not root.is_dir():
296
+ return []
297
+ return [p for p in sorted(root.rglob("*.py")) if p.name != "_state.py"]
298
+
299
+
300
+ def _parse_or_none(py_file: Path) -> ast.Module | None:
301
+ """Parse a file; syntax errors are reported elsewhere."""
302
+ try:
303
+ return ast.parse(py_file.read_text(encoding="utf-8"))
304
+ except (SyntaxError, OSError):
305
+ return None
306
+
307
+
308
+ def _imported_names(
309
+ tree: ast.Module, module: str, names: set[str]
310
+ ) -> set[str]:
311
+ """Local aliases bound to ``names`` imported from ``module``."""
312
+ found: set[str] = set()
313
+ for node in ast.walk(tree):
314
+ if isinstance(node, ast.ImportFrom) and node.module == module:
315
+ for alias in node.names:
316
+ if alias.name in names:
317
+ found.add(alias.asname or alias.name)
318
+ return found
319
+
320
+
321
+ def _check_prompt_templates(src_dir: str) -> list[ValidationError]:
322
+ """Statically mirror ``load_prompt``'s template guard.
323
+
324
+ For every ``load_prompt(__package__, "name", var=...)`` call,
325
+ the prompt file must exist next to the module
326
+ (``prompts/name.md``) and reference each kwarg as ``$var`` or
327
+ ``${var}``. A ``{var}`` placeholder is str.format syntax —
328
+ ``string.Template`` ignores it and the SDK raises at run time;
329
+ this surfaces the same failure at validate time.
330
+
331
+ Only calls whose first argument is the ``__package__`` name are
332
+ checked — that is the SDK's documented calling convention, and
333
+ a dynamic package expression can't be resolved statically.
334
+ """
335
+ from string import Template
336
+
337
+ errors: list[ValidationError] = []
338
+ for py_file in _iter_source_files(src_dir):
339
+ tree = _parse_or_none(py_file)
340
+ if tree is None:
341
+ continue
342
+ aliases = _imported_names(tree, "levain_sdk.prompts", {"load_prompt"})
343
+ if not aliases:
344
+ continue
345
+ for call in ast.walk(tree):
346
+ if not isinstance(call, ast.Call):
347
+ continue
348
+ if not (
349
+ isinstance(call.func, ast.Name) and call.func.id in aliases
350
+ ):
351
+ continue
352
+ if len(call.args) < 2 or not (
353
+ isinstance(call.args[1], ast.Constant)
354
+ and isinstance(call.args[1].value, str)
355
+ ):
356
+ continue
357
+ first = call.args[0]
358
+ if not (isinstance(first, ast.Name) and first.id == "__package__"):
359
+ continue
360
+ name = call.args[1].value
361
+ kwargs = sorted(
362
+ kw.arg for kw in call.keywords if kw.arg is not None
363
+ )
364
+ prompt_file = py_file.parent / "prompts" / f"{name}.md"
365
+ if not prompt_file.is_file():
366
+ errors.append(
367
+ ValidationError(
368
+ stage="source",
369
+ message=(
370
+ f"{py_file.name} loads prompt '{name}' but "
371
+ f"prompts/{name}.md does not exist next to it."
372
+ ),
373
+ )
374
+ )
375
+ continue
376
+ if not kwargs:
377
+ continue
378
+ referenced = set(
379
+ Template(
380
+ prompt_file.read_text(encoding="utf-8")
381
+ ).get_identifiers()
382
+ )
383
+ missing = [k for k in kwargs if k not in referenced]
384
+ if missing:
385
+ errors.append(
386
+ ValidationError(
387
+ stage="source",
388
+ message=(
389
+ f"{py_file.name} passes {missing} to prompt "
390
+ f"'{name}' but prompts/{name}.md never "
391
+ f"references them — use $var or ${{var}} "
392
+ f"(templates are string.Template, not "
393
+ f"str.format, so {{var}} is inert)."
394
+ ),
395
+ )
396
+ )
397
+ return errors
398
+
399
+
400
+ def _check_claude_tool_guards(src_dir: str) -> list[ValidationError]:
401
+ """Flag ``allowed_tools=[]`` used as a no-tools switch.
402
+
403
+ ``levain_sdk.claude.run``/``run_stateless`` offer the built-in
404
+ toolset unless ``tools=[]`` is passed; ``allowed_tools`` is a
405
+ permission allowlist, and an empty one is silently ignored.
406
+ A call that passes an empty ``allowed_tools`` without any
407
+ ``tools`` kwarg almost certainly meant to withhold the tools.
408
+ """
409
+ errors: list[ValidationError] = []
410
+ for py_file in _iter_source_files(src_dir):
411
+ tree = _parse_or_none(py_file)
412
+ if tree is None:
413
+ continue
414
+ aliases = _imported_names(
415
+ tree, "levain_sdk.claude", {"run", "run_stateless"}
416
+ )
417
+ if not aliases:
418
+ continue
419
+ for call in ast.walk(tree):
420
+ if not isinstance(call, ast.Call):
421
+ continue
422
+ if not (
423
+ isinstance(call.func, ast.Name) and call.func.id in aliases
424
+ ):
425
+ continue
426
+ kwargs = {kw.arg: kw.value for kw in call.keywords if kw.arg}
427
+ allowed = kwargs.get("allowed_tools")
428
+ if (
429
+ isinstance(allowed, ast.List)
430
+ and not allowed.elts
431
+ and "tools" not in kwargs
432
+ ):
433
+ errors.append(
434
+ ValidationError(
435
+ stage="source",
436
+ message=(
437
+ f"{py_file.name} passes allowed_tools=[] "
438
+ f"without tools=[] — an empty allowlist "
439
+ f"does not withhold the built-in toolset; "
440
+ f"pass tools=[] to run with no tools."
441
+ ),
442
+ )
443
+ )
444
+ return errors
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "levain-cli"
3
- version = "0.1.0"
3
+ version = "0.1.10"
4
4
  description = "Levain recipe toolchain: PlusCal validator, TLC model checking, and GraphState codegen for agent recipes"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -27,14 +27,14 @@ dependencies = [
27
27
  "tree-sitter==0.26.0",
28
28
  "tree-sitter-tlaplus==1.5.0",
29
29
  "pydantic==2.13.4",
30
- "typer==0.26.8",
30
+ "typer==0.27.2",
31
31
  # Sign-in and credential minting. Imported lazily so validation
32
32
  # never pays for it.
33
33
  "httpx==0.28.1",
34
34
  ]
35
35
 
36
36
  [project.optional-dependencies]
37
- mcp = ["fastmcp==3.4.7"]
37
+ mcp = ["fastmcp==4.0.5"]
38
38
 
39
39
  [project.scripts]
40
40
  levain = "levain_recipe.cli:app"
File without changes
File without changes