lisa-cli 0.2.0__tar.gz → 0.3.1__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 (43) hide show
  1. {lisa_cli-0.2.0/src/lisa_cli.egg-info → lisa_cli-0.3.1}/PKG-INFO +1 -1
  2. lisa_cli-0.3.1/pyproject.toml +177 -0
  3. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/entry.py +13 -7
  4. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/main.py +177 -92
  5. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/render.py +7 -2
  6. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/transport.py +276 -62
  7. {lisa_cli-0.2.0 → lisa_cli-0.3.1/src/lisa_cli.egg-info}/PKG-INFO +1 -1
  8. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/SOURCES.txt +7 -2
  9. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_approval_panel.py +1 -2
  10. lisa_cli-0.3.1/tests/test_changed_coverage.py +158 -0
  11. lisa_cli-0.3.1/tests/test_check_coverage_gate.py +108 -0
  12. lisa_cli-0.3.1/tests/test_check_docs.py +227 -0
  13. lisa_cli-0.3.1/tests/test_direct_io_policy.py +134 -0
  14. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_group_resolution.py +2 -4
  15. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_last_command.py +11 -4
  16. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_models_command.py +4 -6
  17. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_profile_option.py +23 -8
  18. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_prompt_decisions.py +7 -19
  19. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_prompt_transport.py +255 -22
  20. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_render_catalogs.py +3 -2
  21. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_streaming.py +1 -1
  22. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_transport_stage.py +195 -0
  23. lisa_cli-0.3.1/tests/test_update_command.py +249 -0
  24. lisa_cli-0.3.1/tests/test_version_output.py +71 -0
  25. lisa_cli-0.2.0/pyproject.toml +0 -75
  26. lisa_cli-0.2.0/tests/test_run_command.py +0 -132
  27. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/LICENSE +0 -0
  28. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/setup.cfg +0 -0
  29. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/__init__.py +0 -0
  30. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/__init__.py +0 -0
  31. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/expand.py +0 -0
  32. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/dependency_links.txt +0 -0
  33. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/entry_points.txt +0 -0
  34. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/requires.txt +0 -0
  35. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/top_level.txt +0 -0
  36. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_pack_update.py +0 -0
  37. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_prompt_command.py +0 -0
  38. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_prompt_entry.py +0 -0
  39. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_prompt_expand.py +0 -0
  40. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_render_models.py +1 -1
  41. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_restart.py +0 -0
  42. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_runtime_teardown.py +0 -0
  43. {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_verify_command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lisa-cli
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Terminal CLI for the Lisa Harness
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -0,0 +1,177 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.setuptools.packages.find]
6
+ where = ["src"]
7
+
8
+ [project]
9
+ name = "lisa-cli"
10
+ version = "0.3.1"
11
+ description = "Terminal CLI for the Lisa Harness"
12
+ requires-python = ">=3.11"
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ dependencies = [
16
+ "lisa-harness-runtime",
17
+ "lisa-harness-kernel",
18
+ "lisa-harness-sdk",
19
+ "lisa-harness-mcp-servers",
20
+ "lisa-pack-core",
21
+ "lisa-pack-introspect",
22
+ "lisa-pack-memory",
23
+ "lisa-pack-recovery",
24
+ "openai>=1.30.0",
25
+ "rich>=13.7.0",
26
+ "typer>=0.12.3",
27
+ "pyyaml>=6.0",
28
+ "httpx>=0.27.0",
29
+ "pydantic>=2.7.0",
30
+ "python-dotenv>=1.0.0",
31
+ ]
32
+
33
+ [project.scripts]
34
+ lisa = "hosts.cli.main:main"
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pyinstaller>=6.10.0",
39
+ "pytest>=8.0.0",
40
+ "pytest-asyncio>=0.23.0",
41
+ "ruff>=0.8.0",
42
+ "types-PyYAML>=6.0",
43
+ ]
44
+ # Interactive no-args `lisa prompt` editor: single-box input with
45
+ # history. Without the extra the plain EOF-terminated reader is used.
46
+ prompt = ["prompt-toolkit>=3.0"]
47
+
48
+ [tool.pytest.ini_options]
49
+ pythonpath = ["src"]
50
+ testpaths = ["tests"]
51
+ markers = [
52
+ "live: hits real external signal or provider APIs and prints a tester-visible summary",
53
+ "integration: multi-module in-process test; may require local services",
54
+ "eval: deterministic agent regression evals (no model calls, no I/O)",
55
+ ]
56
+
57
+ [tool.coverage.run]
58
+ branch = true
59
+ relative_files = true
60
+ source = ["src"]
61
+ omit = [
62
+ "*/tests/*",
63
+ "*/test_*.py",
64
+ "*/conftest.py",
65
+ ]
66
+ source_pkgs = ["hosts.cli"]
67
+
68
+ [tool.coverage.paths]
69
+ source = [
70
+ "src",
71
+ "*/src",
72
+ ]
73
+
74
+ [tool.coverage.report]
75
+ show_missing = true
76
+ skip_covered = true
77
+ fail_under = 85
78
+ exclude_also = [
79
+ ".*/tests/.*",
80
+ ".*/test_[^/]*\\.py$",
81
+ ]
82
+
83
+ [tool.ruff]
84
+ line-length = 100
85
+ target-version = "py311"
86
+ extend-exclude = ["vulture_allowlist.py"]
87
+
88
+ [tool.ruff.lint]
89
+ select = [
90
+ "E",
91
+ "F",
92
+ "I",
93
+ "UP",
94
+ "B",
95
+ "SIM",
96
+ "PL",
97
+ "C90",
98
+ "RET",
99
+ "RUF",
100
+ "W",
101
+ "YTT",
102
+ "C4",
103
+ "PERF",
104
+ "PIE",
105
+ "FURB",
106
+ "G",
107
+ ]
108
+ ignore = ["PLR0913", "PLC0415", "RUF001", "RUF002", "RUF003"]
109
+
110
+ [tool.ruff.lint.per-file-ignores]
111
+ "**/tests/**" = ["PLC0415", "PLR2004"]
112
+ "**/hosts/cli/main.py" = ["E402", "PLC0415", "PLR0917"]
113
+ "**/hosts/cli/render.py" = ["PLC0415"]
114
+ "**/hosts/cli/entry.py" = ["PLC0415", "RUF001"]
115
+
116
+ [tool.ruff.lint.mccabe]
117
+ max-complexity = 10
118
+
119
+ [tool.ruff.lint.pylint]
120
+ max-returns = 6
121
+ max-branches = 12
122
+ max-statements = 50
123
+ max-locals = 15
124
+
125
+ [tool.ruff.format]
126
+ line-ending = "lf"
127
+
128
+ [tool.mypy]
129
+ python_version = "3.11"
130
+ files = ["src/hosts/cli"]
131
+ mypy_path = ["$MYPY_CONFIG_FILE_DIR/src"]
132
+ explicit_package_bases = true
133
+ strict = true
134
+ warn_unused_ignores = true
135
+ warn_redundant_casts = true
136
+ warn_return_any = true
137
+ no_implicit_optional = true
138
+ check_untyped_defs = true
139
+ disallow_untyped_defs = true
140
+ disallow_incomplete_defs = true
141
+ exclude = ["vulture_allowlist.py", '(^|/)(tests|test_[^/]*\.py$)']
142
+
143
+ [tool.vulture]
144
+ paths = ["src/hosts/cli", "vulture_allowlist.py"]
145
+ min_confidence = 80
146
+ exclude = [
147
+ "**/tests/",
148
+ "**/test_*.py",
149
+ "**/conftest.py",
150
+ "**/*egg-info*/",
151
+ "**/__pycache__/",
152
+ ".venv/",
153
+ ]
154
+ ignore_names = [
155
+ "main",
156
+ "*SignalCollector",
157
+ "*Collector",
158
+ "*ProcessingLoop",
159
+ "*Detector",
160
+ "*Registry",
161
+ "*Store",
162
+ "*Broker",
163
+ "*Catalog",
164
+ "*Spec",
165
+ "*Config",
166
+ "*Policy",
167
+ "*Manifest",
168
+ "*Error",
169
+ "*Exception",
170
+ "*Context",
171
+ "*Fragments",
172
+ "*Sections",
173
+ "*Evidence",
174
+ "*Summary",
175
+ "*Anomalies",
176
+ ]
177
+ ignore_decorators = ["@property"]
@@ -50,10 +50,13 @@ from __future__ import annotations
50
50
  import os
51
51
  import unicodedata
52
52
  from pathlib import Path
53
- from typing import Any
53
+ from typing import TYPE_CHECKING, Any
54
54
 
55
55
  from rich.console import Console
56
56
 
57
+ if TYPE_CHECKING:
58
+ from prompt_toolkit.key_binding import KeyBindings
59
+
57
60
  #: Unicode general categories that extend a grapheme cluster: combining
58
61
  #: marks, enclosing marks, format characters (variation selectors, ZWSP),
59
62
  #: and modifier symbols (skin-tone modifiers). ZWJ itself is ``Cf`` but is
@@ -186,7 +189,7 @@ def settings_file_path(data_dir: str | Path, profile: str) -> Path:
186
189
  """
187
190
  from runtime.config import profile_data_dir # lazy: import cost
188
191
 
189
- base = str(data_dir).strip() if str(data_dir).strip() else ""
192
+ base = str(data_dir).strip() or ""
190
193
  if not base and profile.strip():
191
194
  base = str(profile_data_dir(profile.strip()))
192
195
  if not base:
@@ -289,7 +292,7 @@ def prompt_history_path(data_dir: str | Path, profile: str) -> Path:
289
292
  the profile dir; empty hands the decision to the framework's default
290
293
  (``LISA_DATA_DIR`` > ``~/.lisa``).
291
294
  """
292
- base = str(data_dir).strip() if str(data_dir).strip() else ""
295
+ base = str(data_dir).strip() or ""
293
296
  if not base and profile.strip():
294
297
  from runtime.config import profile_data_dir # lazy: import cost
295
298
 
@@ -429,7 +432,7 @@ def _delete_first_grapheme(text: str) -> int:
429
432
  return i
430
433
 
431
434
 
432
- def _add_navigate_bindings(bindings: Any, *, swapped: bool = True) -> None:
435
+ def _add_navigate_bindings(bindings: KeyBindings, *, swapped: bool = True) -> None:
433
436
  """Bind submit/newline and navigation keys.
434
437
 
435
438
  ``swapped`` (the default) puts newline on Enter and send on
@@ -476,7 +479,7 @@ def _add_navigate_bindings(bindings: Any, *, swapped: bool = True) -> None:
476
479
  event.current_buffer.history_forward()
477
480
 
478
481
 
479
- def _add_grapheme_delete_bindings(bindings: Any) -> None:
482
+ def _add_grapheme_delete_bindings(bindings: KeyBindings) -> None:
480
483
  """Bind backspace (Ctrl-H) and forward Delete to cluster-aware cuts.
481
484
 
482
485
  prompt_toolkit deletes one codepoint, tearing multi-codepoint emoji
@@ -504,14 +507,17 @@ def _add_grapheme_delete_bindings(bindings: Any) -> None:
504
507
  buffer.cursor_position = pos
505
508
 
506
509
 
507
- def _tui_key_bindings(swapped: bool = True) -> Any:
510
+ def _tui_key_bindings(swapped: bool = True) -> KeyBindings:
508
511
  """Build the owned key model for the TUI reader.
509
512
 
510
513
  ``swapped`` selects the send/newline layout (see
511
514
  :func:`_add_navigate_bindings`); this exists so the reader body
512
515
  stays under its complexity budget.
513
516
  """
514
- bindings = _PT["KeyBindings"]()
517
+ pt = _PT
518
+ if pt is None: # pragma: no cover - guarded by the caller's _PT check
519
+ raise RuntimeError("_tui_key_bindings requires the prompt extra")
520
+ bindings: KeyBindings = pt["KeyBindings"]()
515
521
  _add_navigate_bindings(bindings, swapped=swapped)
516
522
  _add_grapheme_delete_bindings(bindings)
517
523
  return bindings
@@ -3,8 +3,8 @@
3
3
  This is the terminal-transport entry point for the Lisa framework. It
4
4
  wraps the transport-neutral :mod:`host` runtime with Typer commands
5
5
  and Rich rendering. Every command is a one-shot process: turns run via
6
- ``lisa prompt`` and lifecycle/inspection commands (verify, models, policy,
7
- pack, mcp, last, intake) run once and exit.
6
+ ``lisa prompt`` and lifecycle/inspection commands (verify, update, models,
7
+ policy, pack, mcp, last, intake) run once and exit.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -14,10 +14,12 @@ import logging
14
14
  import os
15
15
  import platform
16
16
  import shlex
17
+ import shutil
18
+ import subprocess
17
19
  import sys
18
20
  from collections.abc import Callable
19
21
  from pathlib import Path
20
- from typing import TYPE_CHECKING, Annotated, Any, Literal, NamedTuple
22
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, NamedTuple, cast
21
23
 
22
24
  import typer
23
25
  from kernel.models import McpServerHealth
@@ -185,7 +187,7 @@ _TRUTHY = {"1", "true", "yes", "on"}
185
187
  # by ``_cli_callback``) recorded here for the prompt command to OR in when
186
188
  # lazy routing lands there (``lisa -r "fix it"``). One CLI process runs the
187
189
  # group callback at most once, before any command body.
188
- _GROUP_HOIST: dict[str, bool] = {}
190
+ _GROUP_HOIST: dict[str, bool | None] = {}
189
191
 
190
192
  # Env var enabling lazy routing: an unrecognized first positional is
191
193
  # resolved by the custom group (``_LisaGroup.resolve_command``) to the
@@ -299,7 +301,7 @@ def _make_pack_command(name: str) -> Any:
299
301
  bare_prefix = prefix.lstrip("/")
300
302
  if bare_prefix and tokens[0].startswith(bare_prefix) and len(bare_prefix) > len(best):
301
303
  best = bare_prefix
302
- command = "/" + (best if best else name)
304
+ command = "/" + (best or name)
303
305
  for token in args:
304
306
  command += " " + token
305
307
  _run_host_command(
@@ -415,7 +417,7 @@ def _get_version() -> str:
415
417
 
416
418
  return _pkg_version("lisa-cli")
417
419
  except Exception: # pylint: disable=broad-except
418
- return "0.2.0"
420
+ return "0.3.1"
419
421
 
420
422
 
421
423
  _VERSION_OPTION = typer.Option(
@@ -487,8 +489,9 @@ ProfileOption = Annotated[
487
489
  typer.Option(
488
490
  "--profile",
489
491
  help=(
490
- "Named data-dir profile: resolves to ~/.lisa/profiles/<name> "
491
- "(overrides LISA_PROFILE; --data-dir still wins). Shares the "
492
+ "Named data-dir profile: resolves to <profiles-root>/<name> "
493
+ "(default ~/.lisa/profiles; relocatable via LISA_PROFILES_DIR). "
494
+ "Overrides LISA_PROFILE; --data-dir still wins. Shares the "
492
495
  "base packs venv, isolates threads/memory/models/policy."
493
496
  ),
494
497
  ),
@@ -709,7 +712,7 @@ def _initialize_runtime(
709
712
  wiring -> MCP config compilation) lives in the shared bootstrap so
710
713
  the CLI and web hosts cannot drift.
711
714
 
712
- ``profile`` resolves to ``~/.lisa/profiles/<name>`` here (CLI arg >
715
+ ``profile`` resolves to ``<profiles-root>/<name>`` here (CLI arg >
713
716
  ``LISA_PROFILE``; an explicit ``data_dir`` still wins) because
714
717
  ``--data-dir`` and ``--profile`` are both CLI-level data-dir inputs —
715
718
  the framework's ``HostConfig.from_env`` only knows the env var.
@@ -775,7 +778,11 @@ def _render_runtime_error(console: Console, exc: Exception, *, title: str = "Err
775
778
  # ---------------------------------------------------------------------------
776
779
 
777
780
 
778
- @app.callback()
781
+ # invoke_without_command lets the eager --version flag short-circuit on a bare
782
+ # `lisa --version` (Click otherwise raises "Missing command" before the
783
+ # callback runs). With no subcommand and no --version the callback mirrors the
784
+ # group's no-args help + exit 2.
785
+ @app.callback(invoke_without_command=True)
779
786
  def _cli_callback(
780
787
  ctx: typer.Context,
781
788
  version: bool = _VERSION_OPTION,
@@ -819,6 +826,9 @@ def _cli_callback(
819
826
  if version:
820
827
  console.print(f"lisa v{_get_version()}")
821
828
  raise typer.Exit()
829
+ if ctx.invoked_subcommand is None:
830
+ console.print(ctx.get_help())
831
+ raise typer.Exit(code=2)
822
832
 
823
833
 
824
834
  # ---------------------------------------------------------------------------
@@ -1233,7 +1243,7 @@ def _models_command(
1233
1243
  await runtime.start()
1234
1244
  return await handle_models_command(runtime, stripped, stripped)
1235
1245
  finally:
1236
- await runtime.aclose()
1246
+ await _aclose_runtime_bounded(runtime)
1237
1247
 
1238
1248
  response_text = asyncio.run(_run_models_command())
1239
1249
  console.print(Panel(Markdown(response_text), title="Models", border_style="cyan"))
@@ -1900,6 +1910,142 @@ async def _run_pack_update_all(runtime: HostRuntime) -> tuple[bool, str]:
1900
1910
  return await run_pack_update_all(runtime)
1901
1911
 
1902
1912
 
1913
+ # ---------------------------------------------------------------------------
1914
+ # update command
1915
+ # ---------------------------------------------------------------------------
1916
+
1917
+ _UPDATE_TOOL_NAME = "lisa-cli"
1918
+
1919
+ #: uv cannot replace a running console-script trampoline on Windows; the
1920
+ #: command reports the argv for the operator to run after exiting instead.
1921
+ _UPDATE_IN_PLACE_BLOCKED = platform.system() == "Windows"
1922
+
1923
+
1924
+ @app.command(
1925
+ "update",
1926
+ help="Upgrade the installed lisa CLI in place (uv tool upgrade lisa-cli).",
1927
+ )
1928
+ def _update_command(
1929
+ force: bool = typer.Option(
1930
+ False,
1931
+ "--force",
1932
+ help=(
1933
+ "Reinstall even when the recorded version is unchanged "
1934
+ "(picks up new code from a local or git checkout without a version bump)."
1935
+ ),
1936
+ ),
1937
+ dry_run: bool = typer.Option(
1938
+ False,
1939
+ "--dry-run",
1940
+ help="Print the uv command instead of running it.",
1941
+ ),
1942
+ ) -> None:
1943
+ """Upgrade the tool install that provides this ``lisa`` executable.
1944
+
1945
+ ``uv tool upgrade`` is the only correct update path: it replays the
1946
+ install receipt (``<tool-dir>/lisa-cli/uv-receipt.toml``), which is where
1947
+ the installer records the ``--with`` framework requirements, the
1948
+ ``[prompt]`` extra, and ``no-sources``. ``uv tool install --upgrade
1949
+ lisa-cli`` would rewrite that receipt from a bare index spec and drop
1950
+ all of it, so this command never does that.
1951
+ """
1952
+ from runtime.paths import default_packs_venv, is_tool_install
1953
+
1954
+ if not is_tool_install():
1955
+ console.print(
1956
+ "[yellow]lisa is running from a repo checkout, so there is no uv tool to "
1957
+ "upgrade. Update with:[/yellow] git pull && uv sync"
1958
+ )
1959
+ raise typer.Exit(code=2)
1960
+
1961
+ tool_dir = _installed_tool_dir()
1962
+ if tool_dir is None:
1963
+ console.print(
1964
+ "[yellow]This interpreter is not a uv tool install (no uv-receipt.toml in "
1965
+ f"{sys.prefix}), so there is nothing for `uv tool upgrade` to update.[/yellow]"
1966
+ )
1967
+ raise typer.Exit(code=2)
1968
+
1969
+ uv_bin = shutil.which("uv")
1970
+ # Point uv at the tool dir this process is actually running from. Without
1971
+ # it uv consults only UV_TOOL_DIR/default, so an install made with a custom
1972
+ # tool dir would miss the real one and advise `uv tool install lisa-cli` —
1973
+ # the receipt-clobbering path this command exists to avoid.
1974
+ env = {**os.environ, "UV_TOOL_DIR": str(tool_dir.parent)}
1975
+ argv = [
1976
+ uv_bin or "uv",
1977
+ "tool",
1978
+ "upgrade",
1979
+ *(["--reinstall"] if force else []),
1980
+ _UPDATE_TOOL_NAME,
1981
+ ]
1982
+ printable = shlex.join(argv)
1983
+
1984
+ # On Windows the console script is a .exe trampoline inside the tool
1985
+ # venv, which cannot be replaced while this process still has it open.
1986
+ blocked = uv_bin is None or (_UPDATE_IN_PLACE_BLOCKED and not dry_run)
1987
+ if dry_run or blocked:
1988
+ if dry_run:
1989
+ console.print(f"Run: [bold]{printable}[/bold]")
1990
+ else:
1991
+ reason = (
1992
+ "uv is not on PATH; run it yourself"
1993
+ if uv_bin is None
1994
+ else "Windows locks the running lisa.exe"
1995
+ )
1996
+ console.print(f"[yellow]{reason}.[/yellow] Run: [bold]{printable}[/bold]")
1997
+ raise typer.Exit(code=0 if dry_run else 1)
1998
+
1999
+ console.print(f"Running [bold]{printable}[/bold]")
2000
+ completed = subprocess.run(argv, check=False, env=env) # inherits stdio so uv streams progress
2001
+ if completed.returncode != 0:
2002
+ console.print(f"[red]uv tool upgrade failed (exit {completed.returncode}).[/red]")
2003
+ raise typer.Exit(code=completed.returncode)
2004
+
2005
+ console.print("Updated. Restart lisa to use the new version.")
2006
+ _warn_stale_packs_venv(default_packs_venv())
2007
+
2008
+
2009
+ def _installed_tool_dir() -> Path | None:
2010
+ """Return the uv tool directory this process is running from.
2011
+
2012
+ A ``uv tool`` venv carries the receipt at its root, so ``sys.prefix``
2013
+ identifies the install unambiguously — including installs made with a
2014
+ custom ``UV_TOOL_DIR``, which uv would otherwise not find. The receipt's
2015
+ parent is the value ``UV_TOOL_DIR`` expects (it is the directory holding
2016
+ one subdirectory per tool).
2017
+
2018
+ This is a positive check: a plain virtualenv or pipx install has no
2019
+ receipt and returns ``None``.
2020
+ """
2021
+ prefix = Path(sys.prefix)
2022
+ if not (prefix / "uv-receipt.toml").is_file():
2023
+ return None
2024
+ return prefix
2025
+
2026
+
2027
+ def _warn_stale_packs_venv(packs_venv: Path) -> None:
2028
+ """Point the operator at the one follow-up packs may need.
2029
+
2030
+ The packs venv is a second install with its own framework copies. Packs
2031
+ import the CLI's framework packages (the tool venv's site-packages
2032
+ precedes the packs venv on ``sys.path``), so an upgrade does not break
2033
+ them — but a pack whose *dependencies* moved can still need a re-resolve,
2034
+ and ``uv pip install`` never upgrades an already-satisfied requirement.
2035
+ ``lisa pack update --all`` re-installs each pack from its recorded source,
2036
+ which re-resolves its dependency set. Surfacing the command is cheaper
2037
+ than silently re-seeding every pack after an upgrade.
2038
+ """
2039
+ if not packs_venv.is_dir():
2040
+ return
2041
+ console.print(
2042
+ f"[yellow]Packs live in a separate venv ({packs_venv}). Packs keep working "
2043
+ "against the upgraded framework, but their own dependencies are only "
2044
+ "re-resolved by `lisa pack update --all` — run it if a pack misbehaves "
2045
+ "after an update.[/yellow]"
2046
+ )
2047
+
2048
+
1903
2049
  # ---------------------------------------------------------------------------
1904
2050
  # prompt command
1905
2051
  # ---------------------------------------------------------------------------
@@ -1971,8 +2117,10 @@ def _collect_interactive_or_error(
1971
2117
  if sys.stdin.isatty() and sys.stdout.isatty():
1972
2118
  from hosts.cli import entry as entry_mod
1973
2119
 
1974
- reader = _prompt_reader or entry_mod.read_prompt_message_tui
1975
- if reader is entry_mod.read_prompt_message_tui:
2120
+ if _prompt_reader is not None:
2121
+ # Custom reader seam (tests): the plain-reader contract.
2122
+ terminal_message = _prompt_reader(console)
2123
+ else:
1976
2124
  # Flag state -> reader layout (True=shipped, False=chat-box):
1977
2125
  # absent (None) -> env decides (swapped_keys=None)
1978
2126
  # --no-swap-keys -> shipped (swapped_keys=True)
@@ -1984,16 +2132,13 @@ def _collect_interactive_or_error(
1984
2132
  resolved = True
1985
2133
  else:
1986
2134
  resolved = False
1987
- terminal_message = reader(
2135
+ terminal_message = entry_mod.read_prompt_message_tui(
1988
2136
  console,
1989
2137
  history_path=entry_mod.prompt_history_path(data_dir, profile),
1990
2138
  swapped_keys=resolved,
1991
2139
  data_dir=data_dir,
1992
2140
  profile=profile,
1993
2141
  )
1994
- else:
1995
- # Custom reader seam (tests): the plain-reader contract.
1996
- terminal_message = reader(console)
1997
2142
  if terminal_message is None:
1998
2143
  raise typer.Exit(code=_ABORT_EXIT) from None
1999
2144
  if terminal_message.strip():
@@ -2064,7 +2209,7 @@ async def _run_host_command_turn(runtime: HostRuntime, command: str) -> str:
2064
2209
  await runtime.start()
2065
2210
  result = await handle_message(runtime, command, NullTransport())
2066
2211
  finally:
2067
- await runtime.aclose()
2212
+ await _aclose_runtime_bounded(runtime)
2068
2213
  return getattr(result, "text", "") or str(result)
2069
2214
 
2070
2215
 
@@ -2088,7 +2233,9 @@ async def _resolve_pending_request_id(runtime: HostRuntime) -> str:
2088
2233
  logger.debug("prompt: pending-approval resolution failed: %s", exc)
2089
2234
  return ""
2090
2235
  if len(pending) == 1:
2091
- request_id = pending[0].invocation.request_id
2236
+ # The service reaches the CLI as Any (duck-typed seam), so reassert
2237
+ # the declared kernel field type for the return contract.
2238
+ request_id = cast("str", pending[0].invocation.request_id)
2092
2239
  logger.info("prompt: resolved bare decision to pending %s", request_id[:8])
2093
2240
  return request_id
2094
2241
  return ""
@@ -2128,7 +2275,7 @@ async def _decide_approval_turn(
2128
2275
  transport=NullTransport(),
2129
2276
  )
2130
2277
  finally:
2131
- await runtime.aclose()
2278
+ await _aclose_runtime_bounded(runtime)
2132
2279
  return getattr(result, "text", "") or str(result)
2133
2280
 
2134
2281
 
@@ -2392,76 +2539,6 @@ def _deny_command( # Match shared runtime options used by one-shot commands.
2392
2539
  )
2393
2540
 
2394
2541
 
2395
- @app.command(
2396
- "run",
2397
- hidden=True,
2398
- help=(
2399
- "[DEPRECATED] Run one slash command (host admin or pack-defined). "
2400
- "Every host command now has a real subcommand (pack-registered ones "
2401
- "like `stalls`/`retry` are generated from the pack registry); this "
2402
- "escape hatch remains one release for compatibility. The leading '/' "
2403
- "is optional. Quote multi-word arguments as one string; with multiple "
2404
- "tokens they are re-quoted verbatim so the runtime sees the exact "
2405
- "slash-command string."
2406
- ),
2407
- )
2408
- def _run_command_command( # Match shared runtime options used by one-shot commands.
2409
- command: Annotated[
2410
- list[str] | None,
2411
- typer.Argument(help="Slash command and its arguments."),
2412
- ] = None,
2413
- log_level: LogLevelOption = "",
2414
- log: LogOption = "",
2415
- no_log: NoLogOption = False,
2416
- data_dir: DataDirOption = "",
2417
- profile: ProfileOption = "",
2418
- overlays_dir: OverlaysDirOption = "",
2419
- thread_dir: ThreadDirOption = "",
2420
- no_thread: NoThreadOption = False,
2421
- session_context: SessionContextOption = "",
2422
- timezone: TimezoneOption = "",
2423
- state_snapshot: StateSnapshotOption = "",
2424
- streaming: StreamingOption = "",
2425
- host_log: HostLogOption = "",
2426
- ) -> None:
2427
- """Dispatch one slash command to the runtime exactly as a persistent
2428
- host would, via ``handle_message`` and ``_run_host_command_turn``.
2429
-
2430
- DEPRECATED: kept one release for compatibility. Every host admin verb
2431
- now has a tailored Typer command; pack-registered commands (``stalls``,
2432
- ``retry``, ...) resolve as dynamically generated subcommands. This
2433
- generic string escape hatch hides from help and will be removed once
2434
- scripts have migrated.
2435
- """
2436
- console.print(
2437
- "[yellow]`lisa run` is deprecated: host commands are real subcommands "
2438
- "now (`lisa settings`, `lisa stalls`, ...).[/yellow]"
2439
- )
2440
- if not command:
2441
- console.print("[red]No slash command given. Example: lisa run '/policy mode trust'[/red]")
2442
- raise typer.Exit(code=2)
2443
- parts = list(command)
2444
- command_str = parts[0] if len(parts) == 1 else " ".join(shlex.quote(part) for part in parts)
2445
- if not command_str.startswith("/"):
2446
- command_str = "/" + command_str
2447
- _run_host_command(
2448
- command_str,
2449
- log_level=log_level,
2450
- log_path=log,
2451
- no_log=no_log,
2452
- data_dir=data_dir,
2453
- profile=profile,
2454
- overlays_dir=overlays_dir,
2455
- thread_dir=thread_dir,
2456
- no_thread=no_thread,
2457
- session_context=session_context,
2458
- timezone=timezone,
2459
- state_snapshot_path=state_snapshot,
2460
- streaming=streaming,
2461
- host_log_path=host_log,
2462
- )
2463
-
2464
-
2465
2542
  def _compose_prompt_message(instruction: str, payload: str) -> str:
2466
2543
  """Compose the prompt-mode turn message from the CLI instruction and stdin.
2467
2544
 
@@ -2672,14 +2749,22 @@ async def _run_prompt_with_teardown(
2672
2749
  # stop — there is no turn to run.
2673
2750
  decision_text = getattr(result, "text", "") or str(result)
2674
2751
  transport.stop_activity()
2675
- await runtime.aclose()
2752
+ await _aclose_runtime_bounded(runtime)
2676
2753
  await transport.on_response(decision_text)
2677
2754
  return transport
2678
2755
  if message:
2679
2756
  await handle_message(runtime, message, transport)
2680
2757
  finally:
2681
2758
  transport.stop_activity()
2682
- await runtime.aclose()
2759
+ # Bounded teardown: the prompt command previously awaited bare
2760
+ # ``runtime.aclose()`` — when the MCP pool's sequential anyio
2761
+ # task-group teardown stalls (the 2026-09-21 headroom incident
2762
+ # recurred on 2026-10-26 ~16:11: the turn had already completed
2763
+ # and persisted, yet the CLI sat idle in epoll for the rest of
2764
+ # the prompt run because the unbounded close never returned).
2765
+ # The bounded helper falls back to terminating every tracked
2766
+ # MCP/proxy subprocess after the grace window.
2767
+ await _aclose_runtime_bounded(runtime)
2683
2768
  return transport
2684
2769
 
2685
2770
 
@@ -423,8 +423,10 @@ def _containment_badge(containment: str) -> tuple[str, str]:
423
423
  return "✗ NOT SANDBOXED", STYLE_CONTAINMENT_NONE
424
424
 
425
425
 
426
- def _format_approval_expiry(seconds: object) -> str:
426
+ def _format_approval_expiry(seconds: int | float | str | None) -> str:
427
427
  """Format a host-supplied approval TTL for compact panel display."""
428
+ if seconds is None:
429
+ return ""
428
430
  try:
429
431
  remaining = max(0, int(seconds))
430
432
  except (TypeError, ValueError):
@@ -466,7 +468,10 @@ def render_approval_panel(
466
468
  reasons = str(payload.get("containment_reasons") or "").strip()
467
469
  policy_reason = str(payload.get("policy_reason") or "").strip()
468
470
  policy_tier = str(payload.get("policy_tier") or "").strip()
469
- expiry = _format_approval_expiry(payload.get("expires_in_seconds"))
471
+ raw_expiry = payload.get("expires_in_seconds")
472
+ expiry = _format_approval_expiry(
473
+ raw_expiry if isinstance(raw_expiry, (int, float, str)) else None
474
+ )
470
475
 
471
476
  body = Text()
472
477
  if _looks_like_diff(preview or ""):