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.
- {lisa_cli-0.2.0/src/lisa_cli.egg-info → lisa_cli-0.3.1}/PKG-INFO +1 -1
- lisa_cli-0.3.1/pyproject.toml +177 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/entry.py +13 -7
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/main.py +177 -92
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/render.py +7 -2
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/transport.py +276 -62
- {lisa_cli-0.2.0 → lisa_cli-0.3.1/src/lisa_cli.egg-info}/PKG-INFO +1 -1
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/SOURCES.txt +7 -2
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_approval_panel.py +1 -2
- lisa_cli-0.3.1/tests/test_changed_coverage.py +158 -0
- lisa_cli-0.3.1/tests/test_check_coverage_gate.py +108 -0
- lisa_cli-0.3.1/tests/test_check_docs.py +227 -0
- lisa_cli-0.3.1/tests/test_direct_io_policy.py +134 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_group_resolution.py +2 -4
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_last_command.py +11 -4
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_models_command.py +4 -6
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_profile_option.py +23 -8
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_prompt_decisions.py +7 -19
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_prompt_transport.py +255 -22
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_render_catalogs.py +3 -2
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_streaming.py +1 -1
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_transport_stage.py +195 -0
- lisa_cli-0.3.1/tests/test_update_command.py +249 -0
- lisa_cli-0.3.1/tests/test_version_output.py +71 -0
- lisa_cli-0.2.0/pyproject.toml +0 -75
- lisa_cli-0.2.0/tests/test_run_command.py +0 -132
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/LICENSE +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/setup.cfg +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/__init__.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/__init__.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/hosts/cli/expand.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/dependency_links.txt +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/entry_points.txt +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/requires.txt +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/top_level.txt +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_pack_update.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_prompt_command.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_prompt_entry.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_prompt_expand.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_render_models.py +1 -1
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_restart.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_runtime_teardown.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.1}/tests/test_verify_command.py +0 -0
|
@@ -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()
|
|
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()
|
|
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:
|
|
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:
|
|
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) ->
|
|
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
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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
|
|
491
|
-
"(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1975
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 ""):
|