lisa-cli 0.1.0__py3-none-any.whl
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.
- hosts/__init__.py +0 -0
- hosts/cli/__init__.py +0 -0
- hosts/cli/main.py +2692 -0
- hosts/cli/render.py +535 -0
- hosts/cli/transport.py +1623 -0
- lisa_cli-0.1.0.dist-info/METADATA +26 -0
- lisa_cli-0.1.0.dist-info/RECORD +10 -0
- lisa_cli-0.1.0.dist-info/WHEEL +5 -0
- lisa_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lisa_cli-0.1.0.dist-info/top_level.txt +1 -0
hosts/cli/main.py
ADDED
|
@@ -0,0 +1,2692 @@
|
|
|
1
|
+
"""hosts.cli.main — Typer app and CLI entry points for the terminal host.
|
|
2
|
+
|
|
3
|
+
This is the terminal-transport entry point for the Lisa framework. It
|
|
4
|
+
wraps the transport-neutral :mod:`host` runtime with Typer commands
|
|
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.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import shlex
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal, NamedTuple
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from kernel.models import McpServerHealth
|
|
24
|
+
from kernel.tools.mcp_client import MCPToolSpec
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from rich.panel import Panel
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
|
|
29
|
+
# Load .env BEFORE injecting the packs venv site-packages so that
|
|
30
|
+
# LISA_DATA_DIR (and LISA_PACKS_VENV) are honored when resolving the
|
|
31
|
+
# packs venv (``<data_dir>/venv``) location. The loader only needs framework_root() and
|
|
32
|
+
# default_data_dir(), both of which are env-independent, so it can run
|
|
33
|
+
# safely before any host import. Search order (lowest precedence first —
|
|
34
|
+
# every existing candidate is loaded, later ones overriding earlier ones):
|
|
35
|
+
# (1) CWD — for repo-mode dev sessions
|
|
36
|
+
# (2) ~/.lisa/.env — for tool-install mode (uv tool install lisa)
|
|
37
|
+
# (3) project root — repo-mode back-compat
|
|
38
|
+
# (4) hosts/little_lisa/.env — legacy repo-mode back-compat
|
|
39
|
+
from runtime.paths import (
|
|
40
|
+
framework_root as _framework_root,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from dotenv import load_dotenv
|
|
45
|
+
|
|
46
|
+
_FRAMEWORK_ROOT = _framework_root()
|
|
47
|
+
from runtime.config import default_data_dir as _default_data_dir
|
|
48
|
+
from runtime.config import profile_data_dir as _profile_data_dir
|
|
49
|
+
|
|
50
|
+
# Honor LISA_DATA_DIR (and LISA_PROFILE) here so tool-mode installs
|
|
51
|
+
# configured with a custom data dir find their .env before any
|
|
52
|
+
# HostRuntime exists to do the full config resolution. Falls back to
|
|
53
|
+
# the platform default (~/.lisa). Mirrors HostConfig.from_env's
|
|
54
|
+
# precedence: LISA_DATA_DIR > LISA_PROFILE > default_data_dir().
|
|
55
|
+
_data_dir_override = os.getenv("LISA_DATA_DIR", "").strip()
|
|
56
|
+
if _data_dir_override:
|
|
57
|
+
_default_env_dir = Path(_data_dir_override).expanduser()
|
|
58
|
+
else:
|
|
59
|
+
_profile_override = os.getenv("LISA_PROFILE", "").strip()
|
|
60
|
+
try:
|
|
61
|
+
_default_env_dir = (
|
|
62
|
+
_profile_data_dir(_profile_override) if _profile_override else _default_data_dir()
|
|
63
|
+
)
|
|
64
|
+
except ValueError:
|
|
65
|
+
# Invalid profile name: HostConfig.from_env raises the real
|
|
66
|
+
# startup error later; the .env loader falls back to the
|
|
67
|
+
# platform default so the error panel (not an import crash)
|
|
68
|
+
# is what the user sees.
|
|
69
|
+
_default_env_dir = _default_data_dir()
|
|
70
|
+
|
|
71
|
+
# Module-level search order, shared with _verify_check_env (which reads
|
|
72
|
+
# this constant so monkeypatched tests and the loader stay in sync).
|
|
73
|
+
# Ordered lowest-precedence first; _load_env_candidates applies them in
|
|
74
|
+
# this order so later (more authoritative) entries win.
|
|
75
|
+
#
|
|
76
|
+
# CWD first is deliberate — repo-mode dev sessions expect the checkout's
|
|
77
|
+
# own .env to be the base. It is NOT an early-return: loading only the
|
|
78
|
+
# first hit let an incidental CWD .env (a stale repo copy, or any
|
|
79
|
+
# directory the operator happens to be in) shadow the machine-level
|
|
80
|
+
# data-dir .env completely. That silently dropped every setting the
|
|
81
|
+
# authoritative file carries but the shadowing one lacks — the
|
|
82
|
+
# 2026-09-21 headroom incident: HEADROOM_MCP_BIN expanded to "" from the
|
|
83
|
+
# repo .env, so the pack's optional BINARY server failed to compile and
|
|
84
|
+
# `lisa prompt` died with a Startup Error panel.
|
|
85
|
+
#
|
|
86
|
+
# TEST CONTRACT: this import-time load runs during pytest collection,
|
|
87
|
+
# before any fixture can intervene, so operator .env values DO land in
|
|
88
|
+
# os.environ inside test processes. hosts/cli/tests/conftest.py scrubs
|
|
89
|
+
# them (autouse _hermetic_env) — tests own their environment; anything
|
|
90
|
+
# ambient is treated as noise, not config.
|
|
91
|
+
_ENV_SEARCH_CANDIDATES = (
|
|
92
|
+
Path.cwd() / ".env",
|
|
93
|
+
_default_env_dir / ".env",
|
|
94
|
+
_FRAMEWORK_ROOT / ".env",
|
|
95
|
+
_FRAMEWORK_ROOT / "hosts" / "little_lisa" / ".env",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _load_env_candidates(candidates: tuple[Path, ...]) -> Path | None:
|
|
99
|
+
"""Load every existing candidate .env; return the last one loaded.
|
|
100
|
+
|
|
101
|
+
Later candidates are more authoritative than earlier ones: they are
|
|
102
|
+
applied with ``override=True`` so a machine-level data-dir .env can
|
|
103
|
+
fill in (and correct) whatever a lower-precedence file got wrong.
|
|
104
|
+
Within a single file the first definition still wins, so the
|
|
105
|
+
documented "``LISA_DATA_DIR`` above wins when both are set" behavior
|
|
106
|
+
is preserved by ``python-dotenv``'s own no-override default.
|
|
107
|
+
"""
|
|
108
|
+
loaded: Path | None = None
|
|
109
|
+
for candidate in candidates:
|
|
110
|
+
if not candidate.exists():
|
|
111
|
+
continue
|
|
112
|
+
if loaded is not None:
|
|
113
|
+
# More authoritative than everything loaded so far.
|
|
114
|
+
load_dotenv(dotenv_path=candidate, override=True)
|
|
115
|
+
else:
|
|
116
|
+
load_dotenv(dotenv_path=candidate, override=False)
|
|
117
|
+
loaded = candidate
|
|
118
|
+
return loaded
|
|
119
|
+
|
|
120
|
+
_load_env_candidates(_ENV_SEARCH_CANDIDATES)
|
|
121
|
+
except ImportError: # pragma: no cover — python-dotenv is optional
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
# Inject the packs venv site-packages into sys.path BEFORE importing
|
|
125
|
+
# host so that importlib.metadata.entry_points discovers packs
|
|
126
|
+
# installed via /pack install into the packs venv. This must run
|
|
127
|
+
# before any host / kernel import that triggers entry-point scanning.
|
|
128
|
+
from runtime.paths import (
|
|
129
|
+
inject_packs_venv_site_packages as _inject_packs_venv_site_packages,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
_inject_packs_venv_site_packages()
|
|
133
|
+
|
|
134
|
+
from runtime.config import default_data_dir
|
|
135
|
+
|
|
136
|
+
from hosts.cli.render import (
|
|
137
|
+
render_mcp_server_tools,
|
|
138
|
+
render_mcp_servers,
|
|
139
|
+
render_models,
|
|
140
|
+
render_modes,
|
|
141
|
+
render_operations,
|
|
142
|
+
render_pack_list_panel,
|
|
143
|
+
render_tools,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# ``HostRuntime`` is used only as a string annotation (``from __future__
|
|
147
|
+
# import annotations`` stringifies signatures) across the Typer callbacks.
|
|
148
|
+
# The runtime construction happens inside ``_initialize_runtime``, which
|
|
149
|
+
# imports ``HostRuntime`` + the mcp_pool / state_provider symbols lazily so
|
|
150
|
+
# ``--version`` (which exits in the eager Typer callback before any command
|
|
151
|
+
# body runs) does not pay the ~2s ``runtime.runtime`` ->
|
|
152
|
+
# ``kernel.ipsar_fsm`` -> ``openai`` import chain.
|
|
153
|
+
if TYPE_CHECKING:
|
|
154
|
+
from runtime.runtime import HostRuntime
|
|
155
|
+
|
|
156
|
+
from hosts.cli.transport import PromptTransport
|
|
157
|
+
|
|
158
|
+
# ``handle_message`` is deferred to the turn-execution call sites
|
|
159
|
+
# (``_run_prompt_with_teardown``) so ``--version`` / ``modes`` / ``models`` /
|
|
160
|
+
# ``verify`` don't pay the ~3s ``openai`` + ``mcp`` + ``kernel.ipsar_fsm``
|
|
161
|
+
# import cost it drags in. See ``host/__init__.py``'s PEP 562
|
|
162
|
+
# ``__getattr__`` for the package-level lazy import.
|
|
163
|
+
|
|
164
|
+
from rich.traceback import install
|
|
165
|
+
|
|
166
|
+
install(show_locals=True)
|
|
167
|
+
|
|
168
|
+
logger = logging.getLogger(__name__)
|
|
169
|
+
|
|
170
|
+
console = Console()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Group command resolution — unknown routing + dynamic pack subcommands
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Click owns the argv grammar: no pre-Typer argv rewrites. A custom group
|
|
177
|
+
# class resolves unknowns (see ``_LisaGroup`` below) and dynamically
|
|
178
|
+
# generates commands for pack-registered host commands, so `lisa stalls`
|
|
179
|
+
# or `lisa retry <id>` work as real subcommands with --help and completion.
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
183
|
+
|
|
184
|
+
# Group-level hoist flags (``-r``/``-q``/``--no-stdin`` parsed at group level
|
|
185
|
+
# by ``_cli_callback``) recorded here for the prompt command to OR in when
|
|
186
|
+
# lazy routing lands there (``lisa -r "fix it"``). One CLI process runs the
|
|
187
|
+
# group callback at most once, before any command body.
|
|
188
|
+
_GROUP_HOIST: dict[str, bool] = {}
|
|
189
|
+
|
|
190
|
+
# Env var enabling lazy routing: an unrecognized first positional is
|
|
191
|
+
# resolved by the custom group (``_LisaGroup.resolve_command``) to the
|
|
192
|
+
# `prompt` subcommand so users can run `lisa continue` or
|
|
193
|
+
# `lisa "do something else"` without typing `prompt`. Click owns the argv
|
|
194
|
+
# grammar; no pre-Typer rewrites exist.
|
|
195
|
+
_PROMPT_UNKNOWN_ENV = "LISA_PROMPT_UNKNOWN"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _prompt_unknown_active() -> bool:
|
|
199
|
+
"""True if prompt-unknown routing is on (LISA_PROMPT_UNKNOWN)."""
|
|
200
|
+
return os.getenv(_PROMPT_UNKNOWN_ENV, "").strip().lower() in _TRUTHY
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_pack_command_specs: dict[str, Any] | None = None
|
|
204
|
+
"""Cache of pack-registered host-command specs, keyed by bare name.
|
|
205
|
+
|
|
206
|
+
Lazily discovered on first resolution: ``{name: PackCommandEntry}`` where
|
|
207
|
+
``name`` is the first token of the spec prefix (``/stalls`` -> ``stalls``).
|
|
208
|
+
``None`` means discovery has not run yet.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _pack_commands() -> dict[str, Any]:
|
|
213
|
+
"""Return pack-registered host commands by bare name (discovered once).
|
|
214
|
+
|
|
215
|
+
One pack may register several specs sharing a leading verb (``/retry``,
|
|
216
|
+
``/retry-safe``, ...); the bare-name entry keeps the pack's spec list so
|
|
217
|
+
the generated command can dispatch to the longest matching prefix —
|
|
218
|
+
the same matching order the runtime's own dispatcher uses.
|
|
219
|
+
"""
|
|
220
|
+
global _pack_command_specs # noqa: PLW0603 # module-level cache seam
|
|
221
|
+
if _pack_command_specs is None:
|
|
222
|
+
specs: dict[str, Any] = {}
|
|
223
|
+
try:
|
|
224
|
+
from kernel.host_command_discovery import ( # lazy: avoids eager pack scan
|
|
225
|
+
discover_pack_host_commands,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
for entry in discover_pack_host_commands():
|
|
229
|
+
for spec in entry.specs:
|
|
230
|
+
name = spec.prefix.strip().lstrip("/").split(None, 1)[0]
|
|
231
|
+
if name:
|
|
232
|
+
specs.setdefault(name, entry)
|
|
233
|
+
except Exception: # pylint: disable=broad-except # packs venv may be absent
|
|
234
|
+
logger.debug("pack host-command discovery failed", exc_info=True)
|
|
235
|
+
_pack_command_specs = specs
|
|
236
|
+
return _pack_command_specs
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _make_pack_command(name: str) -> Any:
|
|
240
|
+
"""Build a click command dispatching one pack-registered host command.
|
|
241
|
+
|
|
242
|
+
The generated command takes the same shared runtime options as the
|
|
243
|
+
curated one-shot commands plus a catch-all ``ARGS`` argument list; the
|
|
244
|
+
tokens are joined into the pack's slash-command string so the runtime
|
|
245
|
+
dispatches to the pack's own handler exactly as a persistent host
|
|
246
|
+
would (the pack handler owns the sub-grammar, e.g. ``/retry <id>``).
|
|
247
|
+
"""
|
|
248
|
+
import click # lazy: click is a Typer dependency
|
|
249
|
+
|
|
250
|
+
entry = _pack_commands()[name]
|
|
251
|
+
help_text = ""
|
|
252
|
+
for spec in entry.specs:
|
|
253
|
+
if spec.help_text:
|
|
254
|
+
help_text = spec.help_text
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
@click.command(
|
|
258
|
+
name=name,
|
|
259
|
+
help=help_text or f"Run the {name} pack host command.",
|
|
260
|
+
)
|
|
261
|
+
@click.option("--log-level", default="", help="Log threshold: debug, info, warning, error.")
|
|
262
|
+
@click.option("--log", default="", help="Append JSONL turn logs to this file.")
|
|
263
|
+
@click.option("--no-log", is_flag=True, default=False, help="Disable turn logging.")
|
|
264
|
+
@click.option("--data-dir", default="", help="Data root (overrides LISA_DATA_DIR).")
|
|
265
|
+
@click.option("--profile", default="", help="Named data-dir profile.")
|
|
266
|
+
@click.option("--overlays-dir", default="", help="User-authored overlays directory.")
|
|
267
|
+
@click.option("--thread-dir", default="", help="Thread log directory.")
|
|
268
|
+
@click.option("--no-thread", is_flag=True, default=False, help="Disable thread logging.")
|
|
269
|
+
@click.option("--session-context", default="", help="Markdown session plan file path.")
|
|
270
|
+
@click.option("--timezone", default="", help="IANA timezone.")
|
|
271
|
+
@click.option("--state-snapshot", default="", help="Local JSON snapshot to inject.")
|
|
272
|
+
@click.option("--streaming", default="", help="Streaming mode: none, stage, text, hybrid.")
|
|
273
|
+
@click.option("--host-log", default="", help="Stdlib host log file.")
|
|
274
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
275
|
+
def _pack_cmd( # Click options mirror the shared runtime options.
|
|
276
|
+
args: tuple[str, ...],
|
|
277
|
+
log_level: str,
|
|
278
|
+
log: str,
|
|
279
|
+
no_log: bool,
|
|
280
|
+
data_dir: str,
|
|
281
|
+
profile: str,
|
|
282
|
+
overlays_dir: str,
|
|
283
|
+
thread_dir: str,
|
|
284
|
+
no_thread: bool,
|
|
285
|
+
session_context: str,
|
|
286
|
+
timezone: str,
|
|
287
|
+
state_snapshot: str,
|
|
288
|
+
streaming: str,
|
|
289
|
+
host_log: str,
|
|
290
|
+
) -> None:
|
|
291
|
+
tokens = [name, *args]
|
|
292
|
+
# Longest-prefix match mirrors runtime dispatch order (a bare
|
|
293
|
+
# ``/retry`` must not shadow ``/retry-safe``). Prefixes arrive with
|
|
294
|
+
# their leading slash (``/stalls``); the dispatched command is
|
|
295
|
+
# rebuilt from the bare form.
|
|
296
|
+
best = ""
|
|
297
|
+
for spec in entry.specs:
|
|
298
|
+
prefix = spec.prefix.strip().rstrip("/")
|
|
299
|
+
bare_prefix = prefix.lstrip("/")
|
|
300
|
+
if bare_prefix and tokens[0].startswith(bare_prefix) and len(bare_prefix) > len(best):
|
|
301
|
+
best = bare_prefix
|
|
302
|
+
command = "/" + (best if best else name)
|
|
303
|
+
for token in args:
|
|
304
|
+
command += " " + token
|
|
305
|
+
_run_host_command(
|
|
306
|
+
command,
|
|
307
|
+
log_level=log_level,
|
|
308
|
+
log_path=log,
|
|
309
|
+
no_log=no_log,
|
|
310
|
+
data_dir=data_dir,
|
|
311
|
+
profile=profile,
|
|
312
|
+
overlays_dir=overlays_dir,
|
|
313
|
+
thread_dir=thread_dir,
|
|
314
|
+
no_thread=no_thread,
|
|
315
|
+
session_context=session_context,
|
|
316
|
+
timezone=timezone,
|
|
317
|
+
state_snapshot_path=state_snapshot,
|
|
318
|
+
streaming=streaming,
|
|
319
|
+
host_log_path=host_log,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return _pack_cmd
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class _LisaGroup(typer.core.TyperGroup):
|
|
326
|
+
"""Typer group with unknown-command routing and dynamic pack commands.
|
|
327
|
+
|
|
328
|
+
Click itself owns the argv grammar here (no pre-Typer rewrites):
|
|
329
|
+
|
|
330
|
+
- Unknown first positional + ``LISA_PROMPT_UNKNOWN`` on → ``prompt``
|
|
331
|
+
(the remaining tokens become the one-shot message; the group-level
|
|
332
|
+
``-r/-q`` hoist options carry the prompt-only flags).
|
|
333
|
+
- A first positional matching a pack-registered host command name
|
|
334
|
+
(``stalls``, ``retry``, ...) → a dynamically generated command that
|
|
335
|
+
dispatches to the pack's handler (one process, no model turn).
|
|
336
|
+
- A first positional starting with ``/`` → a usage error pointing at
|
|
337
|
+
the named command (``lisa /settings`` → ``try 'lisa settings'``).
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
def get_command(self, ctx: Any, cmd_name: str) -> Any:
|
|
341
|
+
if cmd_name.startswith("/"):
|
|
342
|
+
bare = cmd_name.lstrip("/") or None
|
|
343
|
+
if bare and (super().get_command(ctx, bare) is not None or bare in _pack_commands()):
|
|
344
|
+
raise typer.BadParameter(f"Slash commands are not subcommands. Try 'lisa {bare}'.")
|
|
345
|
+
raise typer.BadParameter(
|
|
346
|
+
f"Slash commands are not subcommands. Try 'lisa {cmd_name}' "
|
|
347
|
+
"without the leading '/'."
|
|
348
|
+
)
|
|
349
|
+
cmd = super().get_command(ctx, cmd_name)
|
|
350
|
+
if cmd is not None:
|
|
351
|
+
return cmd
|
|
352
|
+
if cmd_name in _pack_commands():
|
|
353
|
+
return _make_pack_command(cmd_name)
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
def resolve_command(self, ctx: Any, args: list[str]) -> tuple[str | None, Any, list[str]]:
|
|
357
|
+
cmd_name = args[0]
|
|
358
|
+
cmd = self.get_command(ctx, cmd_name)
|
|
359
|
+
if cmd is None and _prompt_unknown_active():
|
|
360
|
+
prompt_cmd = super().get_command(ctx, "prompt")
|
|
361
|
+
if prompt_cmd is not None:
|
|
362
|
+
return "prompt", prompt_cmd, list(args)
|
|
363
|
+
return super().resolve_command(ctx, args)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
app = typer.Typer(
|
|
367
|
+
cls=_LisaGroup,
|
|
368
|
+
no_args_is_help=True,
|
|
369
|
+
help="Run the Lisa framework terminal host.",
|
|
370
|
+
)
|
|
371
|
+
policy_app = typer.Typer(
|
|
372
|
+
invoke_without_command=True,
|
|
373
|
+
no_args_is_help=False,
|
|
374
|
+
help="Inspect and manage approval policy without starting a model turn.",
|
|
375
|
+
)
|
|
376
|
+
policy_rule_app = typer.Typer(no_args_is_help=True, help="Manage allow, ask, and deny rules.")
|
|
377
|
+
policy_app.add_typer(policy_rule_app, name="rule")
|
|
378
|
+
app.add_typer(policy_app, name="policy")
|
|
379
|
+
|
|
380
|
+
# Lisa palette accent for response separators (kept in sync with the shared
|
|
381
|
+
# palette in transport.py — the same value PromptTransport uses for the
|
|
382
|
+
# "Response" rule).
|
|
383
|
+
_STYLE_RESPONSE = "dark_sea_green4"
|
|
384
|
+
|
|
385
|
+
# Exit code returned when a restart is required (e.g. after a pack lifecycle
|
|
386
|
+
# change). Scripts can detect this to distinguish "restart needed" from a
|
|
387
|
+
# clean exit; re-running the command in a fresh process reloads entry points,
|
|
388
|
+
# pack catalogs, and MCP manifests. Matches the BSD sysexits.h value for
|
|
389
|
+
# EX_UNAVAILABLE.
|
|
390
|
+
_RESTART_REQUIRED_EXIT = 69
|
|
391
|
+
|
|
392
|
+
# Exit code returned when a `prompt` turn ends with an unresolved approval
|
|
393
|
+
# request. A prompt run has no interactive loop to decide an approval, so
|
|
394
|
+
# the request stays durable and the process exits non-zero — scripts detect
|
|
395
|
+
# "needs approval" instead of mistaking it for a completed turn.
|
|
396
|
+
_APPROVAL_REQUIRED_EXIT = 3
|
|
397
|
+
|
|
398
|
+
# Exit code returned when a `prompt` turn ends in a model yield (the model
|
|
399
|
+
# asked the operator a question instead of finishing). The question is
|
|
400
|
+
# durable on the thread and printed to stderr; stdout gets nothing. Scripts
|
|
401
|
+
# detect "needs input" and re-run with the missing information in the prompt.
|
|
402
|
+
_YIELD_UNRESOLVED_EXIT = 4
|
|
403
|
+
|
|
404
|
+
# Exit code returned when the operator aborts a prompt turn with Ctrl-C
|
|
405
|
+
# (SIGINT). The conventional 130 = 128 + SIGINT distinguishes an
|
|
406
|
+
# operator-initiated abort from a failed turn so shells and scripts can
|
|
407
|
+
# treat it as "cancelled, not crashed" (the 2026-10-06 marathon-hang: ^C
|
|
408
|
+
# during a long glm-5.3:cloud generation surfaced as a 40-line uncaught
|
|
409
|
+
# CancelledError traceback instead of a one-line abort notice).
|
|
410
|
+
_ABORT_EXIT = 130
|
|
411
|
+
|
|
412
|
+
# Sentinel for --approve/--deny meaning "the (single) pending approval":
|
|
413
|
+
# resolved to the concrete request id before dispatch via the approval
|
|
414
|
+
# service's list_pending (recorded-owner identity makes cross-process
|
|
415
|
+
# pending records visible to a fresh process).
|
|
416
|
+
_PENDING_APPROVAL_TARGET = "pending"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _get_version() -> str:
|
|
420
|
+
try:
|
|
421
|
+
from importlib.metadata import version as _pkg_version
|
|
422
|
+
|
|
423
|
+
return _pkg_version("lisa-cli")
|
|
424
|
+
except Exception: # pylint: disable=broad-except
|
|
425
|
+
return "0.1.0"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
_VERSION_OPTION = typer.Option(
|
|
429
|
+
False,
|
|
430
|
+
"--version",
|
|
431
|
+
help="Print the lisa version and exit.",
|
|
432
|
+
is_eager=True,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
# Shared Typer option aliases
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
# These Annotated type aliases define each runtime option exactly once so every
|
|
439
|
+
# subcommand shares identical flag names, defaults, and help text.
|
|
440
|
+
|
|
441
|
+
LogLevelOption = Annotated[
|
|
442
|
+
str,
|
|
443
|
+
typer.Option(
|
|
444
|
+
"--log-level",
|
|
445
|
+
help=("Log threshold: debug, info, warning, or error. Overrides LISA_LOG_LEVEL."),
|
|
446
|
+
),
|
|
447
|
+
]
|
|
448
|
+
LogOption = Annotated[
|
|
449
|
+
str,
|
|
450
|
+
typer.Option(
|
|
451
|
+
"--log",
|
|
452
|
+
help="Append JSONL request/response turn logs to this file.",
|
|
453
|
+
),
|
|
454
|
+
]
|
|
455
|
+
NoLogOption = Annotated[
|
|
456
|
+
bool,
|
|
457
|
+
typer.Option(
|
|
458
|
+
"--no-log",
|
|
459
|
+
help="Disable turn logging entirely.",
|
|
460
|
+
),
|
|
461
|
+
]
|
|
462
|
+
AllPacksOption = Annotated[
|
|
463
|
+
bool,
|
|
464
|
+
typer.Option(
|
|
465
|
+
"--all",
|
|
466
|
+
help=(
|
|
467
|
+
"Update every runtime-installed pack with a recorded install source (pack update only)."
|
|
468
|
+
),
|
|
469
|
+
),
|
|
470
|
+
]
|
|
471
|
+
HostLogOption = Annotated[
|
|
472
|
+
str,
|
|
473
|
+
typer.Option(
|
|
474
|
+
"--host-log",
|
|
475
|
+
help=(
|
|
476
|
+
"Stdlib host log file (kernel/sdk startup and runtime logs). "
|
|
477
|
+
"Default: <data-dir>/host.jsonl"
|
|
478
|
+
),
|
|
479
|
+
),
|
|
480
|
+
]
|
|
481
|
+
DataDirOption = Annotated[
|
|
482
|
+
str,
|
|
483
|
+
typer.Option(
|
|
484
|
+
"--data-dir",
|
|
485
|
+
help=(
|
|
486
|
+
"Data root for artifacts, memory, and logs. "
|
|
487
|
+
"Overrides LISA_DATA_DIR. "
|
|
488
|
+
"Default: ~/.lisa (see default_data_dir())"
|
|
489
|
+
),
|
|
490
|
+
),
|
|
491
|
+
]
|
|
492
|
+
ProfileOption = Annotated[
|
|
493
|
+
str,
|
|
494
|
+
typer.Option(
|
|
495
|
+
"--profile",
|
|
496
|
+
help=(
|
|
497
|
+
"Named data-dir profile: resolves to ~/.lisa/profiles/<name> "
|
|
498
|
+
"(overrides LISA_PROFILE; --data-dir still wins). Shares the "
|
|
499
|
+
"base packs venv, isolates threads/memory/models/policy."
|
|
500
|
+
),
|
|
501
|
+
),
|
|
502
|
+
]
|
|
503
|
+
OverlaysDirOption = Annotated[
|
|
504
|
+
str,
|
|
505
|
+
typer.Option(
|
|
506
|
+
"--overlays-dir",
|
|
507
|
+
help=(
|
|
508
|
+
"User-authored overlays directory for mode and content overrides. "
|
|
509
|
+
"Default: <data-dir>/overlays"
|
|
510
|
+
),
|
|
511
|
+
),
|
|
512
|
+
]
|
|
513
|
+
ThreadDirOption = Annotated[
|
|
514
|
+
str,
|
|
515
|
+
typer.Option(
|
|
516
|
+
"--thread-dir",
|
|
517
|
+
help=(
|
|
518
|
+
"Thread log directory. Overrides LISA_THREAD_DIR. "
|
|
519
|
+
"Defaults to <data-dir>/threads when a session context is active."
|
|
520
|
+
),
|
|
521
|
+
),
|
|
522
|
+
]
|
|
523
|
+
NoThreadOption = Annotated[
|
|
524
|
+
bool,
|
|
525
|
+
typer.Option(
|
|
526
|
+
"--no-thread",
|
|
527
|
+
help="Disable local turn history thread logging.",
|
|
528
|
+
),
|
|
529
|
+
]
|
|
530
|
+
SessionContextOption = Annotated[
|
|
531
|
+
str,
|
|
532
|
+
typer.Option(
|
|
533
|
+
"--session-context",
|
|
534
|
+
help=(
|
|
535
|
+
"Path to a Markdown session plan file. "
|
|
536
|
+
"Overrides LISA_SESSION_CONTEXT_PATH. "
|
|
537
|
+
"Default: <data-dir>/config/session_context.md, else the bundled "
|
|
538
|
+
"core session."
|
|
539
|
+
),
|
|
540
|
+
),
|
|
541
|
+
]
|
|
542
|
+
TimezoneOption = Annotated[
|
|
543
|
+
str,
|
|
544
|
+
typer.Option(
|
|
545
|
+
"--timezone",
|
|
546
|
+
help=(
|
|
547
|
+
"IANA timezone (e.g. UTC, America/New_York). "
|
|
548
|
+
"Overrides LISA_TIMEZONE, then OS timezone env vars."
|
|
549
|
+
),
|
|
550
|
+
),
|
|
551
|
+
]
|
|
552
|
+
StateSnapshotOption = Annotated[
|
|
553
|
+
str,
|
|
554
|
+
typer.Option(
|
|
555
|
+
"--state-snapshot",
|
|
556
|
+
help="Local JSON snapshot file to inject compact context from.",
|
|
557
|
+
),
|
|
558
|
+
]
|
|
559
|
+
StreamingOption = Annotated[
|
|
560
|
+
str,
|
|
561
|
+
typer.Option(
|
|
562
|
+
"--streaming",
|
|
563
|
+
help=(
|
|
564
|
+
"Streaming mode: none, stage, text, hybrid (default hybrid). "
|
|
565
|
+
"text streams the answer, hybrid streams thinking, into a "
|
|
566
|
+
"bounded box under the status line; the final answer always "
|
|
567
|
+
"re-renders with full markdown. Append ':<ms>' for "
|
|
568
|
+
"per-character delay (e.g. hybrid:50)."
|
|
569
|
+
),
|
|
570
|
+
),
|
|
571
|
+
]
|
|
572
|
+
QuietOption = Annotated[
|
|
573
|
+
bool,
|
|
574
|
+
typer.Option(
|
|
575
|
+
"--quiet",
|
|
576
|
+
"-q",
|
|
577
|
+
help="Suppress the activity line, per-tool lines, and the "
|
|
578
|
+
"⚡ footer so the response is the only output (pipe-friendly).",
|
|
579
|
+
),
|
|
580
|
+
]
|
|
581
|
+
StdinOption = Annotated[
|
|
582
|
+
bool,
|
|
583
|
+
typer.Option(
|
|
584
|
+
"--stdin/--no-stdin",
|
|
585
|
+
help=(
|
|
586
|
+
"Read piped stdin (when stdin is not a tty) and attach it to the "
|
|
587
|
+
"turn as a <piped-input> block ahead of the message. Default: auto."
|
|
588
|
+
),
|
|
589
|
+
),
|
|
590
|
+
]
|
|
591
|
+
RawOption = Annotated[
|
|
592
|
+
bool,
|
|
593
|
+
typer.Option(
|
|
594
|
+
"--raw",
|
|
595
|
+
"-r",
|
|
596
|
+
help=(
|
|
597
|
+
"Write the response verbatim to stdout for redirection/piping — no "
|
|
598
|
+
"Rule header, no Markdown re-render, no footer — move activity "
|
|
599
|
+
"output to stderr, and prepend a raw-mode output contract to the "
|
|
600
|
+
"turn so the model emits only the requested result."
|
|
601
|
+
),
|
|
602
|
+
),
|
|
603
|
+
]
|
|
604
|
+
ToolDetailOption = Annotated[
|
|
605
|
+
int,
|
|
606
|
+
typer.Option(
|
|
607
|
+
"--tool-detail",
|
|
608
|
+
"-v",
|
|
609
|
+
count=True,
|
|
610
|
+
help=(
|
|
611
|
+
"Show more tool-call detail: -v adds the result preview under "
|
|
612
|
+
"each completed tool row; -v -v also prints the full call "
|
|
613
|
+
"arguments and the multi-line result snippet. Default: compact "
|
|
614
|
+
"one-line rows."
|
|
615
|
+
),
|
|
616
|
+
),
|
|
617
|
+
]
|
|
618
|
+
PromptApproveOption = Annotated[
|
|
619
|
+
str,
|
|
620
|
+
typer.Option(
|
|
621
|
+
"--approve",
|
|
622
|
+
help=(
|
|
623
|
+
"Pre-arm approval for the request id the upcoming turn raises: "
|
|
624
|
+
"`lisa prompt --approve <request-id> 'continue'` dispatches "
|
|
625
|
+
"/approve first, then runs the turn. Use with the durable id "
|
|
626
|
+
"printed by a prior exit-3 run. `--approve` (bare) targets the "
|
|
627
|
+
"single pending approval."
|
|
628
|
+
),
|
|
629
|
+
),
|
|
630
|
+
]
|
|
631
|
+
PromptDenyOption = Annotated[
|
|
632
|
+
str,
|
|
633
|
+
typer.Option(
|
|
634
|
+
"--deny",
|
|
635
|
+
help=(
|
|
636
|
+
"Pre-arm a denial for the upcoming turn: "
|
|
637
|
+
"`lisa prompt --deny <request-id> 'continue'` dispatches /deny first. "
|
|
638
|
+
"`--deny` (bare) targets the single pending approval."
|
|
639
|
+
),
|
|
640
|
+
),
|
|
641
|
+
]
|
|
642
|
+
PromptRememberOption = Annotated[
|
|
643
|
+
str,
|
|
644
|
+
typer.Option(
|
|
645
|
+
"--remember",
|
|
646
|
+
help=(
|
|
647
|
+
"With --approve: persist an allow rule for the decided action's "
|
|
648
|
+
"command prefix (default: program + first subcommand token) so "
|
|
649
|
+
"future matching runs auto-approve. "
|
|
650
|
+
"Pass an explicit prefix to widen/narrow the scope."
|
|
651
|
+
),
|
|
652
|
+
),
|
|
653
|
+
]
|
|
654
|
+
# ---------------------------------------------------------------------------
|
|
655
|
+
# Runtime initialization (builds HostRuntime, not _RUNTIME_STATE)
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _resolve_profiled_data_dir(data_dir: str, profile: str) -> str:
|
|
660
|
+
"""Resolve the effective data dir from --data-dir / --profile.
|
|
661
|
+
|
|
662
|
+
Precedence (matching ``HostConfig.from_env``): explicit ``--data-dir``
|
|
663
|
+
wins; then ``--profile`` (CLI arg); then the framework falls back to
|
|
664
|
+
``LISA_DATA_DIR`` > ``LISA_PROFILE`` > ``~/.lisa``. Returning an empty
|
|
665
|
+
string hands the decision to the framework so the env-var chain stays
|
|
666
|
+
in one place. An invalid profile name raises ``ValueError``, which
|
|
667
|
+
``_initialize_runtime_or_exit`` renders as a startup-error panel.
|
|
668
|
+
"""
|
|
669
|
+
if data_dir.strip():
|
|
670
|
+
return data_dir
|
|
671
|
+
name = profile.strip()
|
|
672
|
+
if not name:
|
|
673
|
+
return ""
|
|
674
|
+
from runtime.config import profile_data_dir
|
|
675
|
+
|
|
676
|
+
return str(profile_data_dir(name))
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _initialize_runtime(
|
|
680
|
+
*,
|
|
681
|
+
log_level: str = "",
|
|
682
|
+
log_path: str = "",
|
|
683
|
+
no_log: bool = False,
|
|
684
|
+
data_dir: str = "",
|
|
685
|
+
profile: str = "",
|
|
686
|
+
overlays_dir: str = "",
|
|
687
|
+
thread_dir: str = "",
|
|
688
|
+
no_thread: bool = False,
|
|
689
|
+
session_context: str = "",
|
|
690
|
+
timezone: str = "",
|
|
691
|
+
state_snapshot_path: str,
|
|
692
|
+
streaming: str = "",
|
|
693
|
+
host_log_path: str = "",
|
|
694
|
+
in_process_mcp: bool = False,
|
|
695
|
+
) -> HostRuntime:
|
|
696
|
+
"""Build and wire a HostRuntime from CLI args + env vars.
|
|
697
|
+
|
|
698
|
+
Thin adapter over :func:`runtime.bootstrap.build_runtime` that
|
|
699
|
+
stamps ``host_kind="cli"`` and forwards every CLI argument. The
|
|
700
|
+
composition sequence (config -> runtime -> state snapshot -> framework
|
|
701
|
+
wiring -> MCP config compilation) lives in the shared bootstrap so
|
|
702
|
+
the CLI and web hosts cannot drift.
|
|
703
|
+
|
|
704
|
+
``profile`` resolves to ``~/.lisa/profiles/<name>`` here (CLI arg >
|
|
705
|
+
``LISA_PROFILE``; an explicit ``data_dir`` still wins) because
|
|
706
|
+
``--data-dir`` and ``--profile`` are both CLI-level data-dir inputs —
|
|
707
|
+
the framework's ``HostConfig.from_env`` only knows the env var.
|
|
708
|
+
|
|
709
|
+
``session_context`` is the optional path to a Markdown session plan
|
|
710
|
+
file; ``user_slug`` is left to the framework (it derives from the
|
|
711
|
+
compiled plan's manifest name). The MCP pool is NOT started here —
|
|
712
|
+
``runtime.start()`` must be called inside the host's event loop (see
|
|
713
|
+
the prompt/verify paths).
|
|
714
|
+
"""
|
|
715
|
+
from runtime.bootstrap import build_runtime
|
|
716
|
+
|
|
717
|
+
return build_runtime(
|
|
718
|
+
host_kind="cli",
|
|
719
|
+
log_level=log_level,
|
|
720
|
+
log_path=log_path,
|
|
721
|
+
no_log=no_log,
|
|
722
|
+
data_dir=_resolve_profiled_data_dir(data_dir, profile),
|
|
723
|
+
overlays_dir=overlays_dir,
|
|
724
|
+
thread_dir=thread_dir,
|
|
725
|
+
no_thread=no_thread,
|
|
726
|
+
timezone=timezone,
|
|
727
|
+
session_context=session_context,
|
|
728
|
+
state_snapshot_path=state_snapshot_path,
|
|
729
|
+
streaming=streaming,
|
|
730
|
+
host_log_path=host_log_path,
|
|
731
|
+
in_process_mcp=in_process_mcp,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _initialize_runtime_or_exit(**kwargs: Any) -> HostRuntime | None:
|
|
736
|
+
"""Initialize runtime, rendering startup errors and exiting on failure.
|
|
737
|
+
|
|
738
|
+
Returns the runtime on success, or ``None`` after calling
|
|
739
|
+
``typer.Exit`` on failure (the ``typer.Exit`` raises, so ``None`` is
|
|
740
|
+
only reached if the exit is suppressed).
|
|
741
|
+
"""
|
|
742
|
+
try:
|
|
743
|
+
return _initialize_runtime(**kwargs)
|
|
744
|
+
except (ValueError, FileNotFoundError, RuntimeError) as exc:
|
|
745
|
+
_render_runtime_error(console, exc, title="Startup Error")
|
|
746
|
+
raise typer.Exit(code=2) from exc
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _render_runtime_error(console: Console, exc: Exception, *, title: str = "Error") -> None:
|
|
750
|
+
"""Render a runtime/startup error in a red Rich panel.
|
|
751
|
+
|
|
752
|
+
The exception's first line is bolded up to its first colon so the cause
|
|
753
|
+
reads at a glance. (Migrated from the retired REPL module; the prompt
|
|
754
|
+
and startup paths are its remaining consumers.)
|
|
755
|
+
"""
|
|
756
|
+
lines = str(exc).splitlines()
|
|
757
|
+
if lines:
|
|
758
|
+
head, sep, tail = lines[0].partition(":")
|
|
759
|
+
if sep:
|
|
760
|
+
lines[0] = f"[bold]{head.strip()}[/bold]:{tail}"
|
|
761
|
+
body = "\n".join(lines) or exc.__class__.__name__
|
|
762
|
+
console.print(Panel(body, title=title, border_style="indian_red"))
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# ---------------------------------------------------------------------------
|
|
766
|
+
# Restart helper
|
|
767
|
+
# ---------------------------------------------------------------------------
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _exit_restart_required() -> None:
|
|
771
|
+
"""Print a restart-required notice and exit for one-shot commands.
|
|
772
|
+
|
|
773
|
+
Used by ``prompt`` and ``pack install/uninstall/update`` after a successful
|
|
774
|
+
pack lifecycle change. One-shot commands do NOT re-exec (re-running the
|
|
775
|
+
same command would redo the mutation). Instead, print a clear notice and
|
|
776
|
+
exit with :data:`_RESTART_REQUIRED_EXIT` on all platforms so the user
|
|
777
|
+
manually re-runs the next command with a fresh process.
|
|
778
|
+
"""
|
|
779
|
+
console.print(
|
|
780
|
+
Panel(
|
|
781
|
+
"A runtime change requires a restart to reload entry points,\n"
|
|
782
|
+
"pack catalogs, and MCP manifests.\n\n"
|
|
783
|
+
"Please re-run [bold]lisa[/bold] to continue.",
|
|
784
|
+
title="Restart Required",
|
|
785
|
+
border_style="gold3",
|
|
786
|
+
)
|
|
787
|
+
)
|
|
788
|
+
raise typer.Exit(code=_RESTART_REQUIRED_EXIT)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
# ---------------------------------------------------------------------------
|
|
792
|
+
# Typer callback
|
|
793
|
+
# ---------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
@app.callback()
|
|
797
|
+
def _cli_callback(
|
|
798
|
+
ctx: typer.Context,
|
|
799
|
+
version: bool = _VERSION_OPTION,
|
|
800
|
+
raw: bool = typer.Option(
|
|
801
|
+
False,
|
|
802
|
+
"--raw",
|
|
803
|
+
"-r",
|
|
804
|
+
hidden=True,
|
|
805
|
+
help="With lazy prompt routing: hoist -r/--raw onto the injected prompt turn.",
|
|
806
|
+
),
|
|
807
|
+
quiet: bool = typer.Option(
|
|
808
|
+
False,
|
|
809
|
+
"--quiet",
|
|
810
|
+
"-q",
|
|
811
|
+
hidden=True,
|
|
812
|
+
help="With lazy prompt routing: hoist -q/--quiet onto the injected prompt turn.",
|
|
813
|
+
),
|
|
814
|
+
no_stdin: bool = typer.Option(
|
|
815
|
+
False,
|
|
816
|
+
"--no-stdin",
|
|
817
|
+
hidden=True,
|
|
818
|
+
help="With lazy prompt routing: hoist --no-stdin onto the injected prompt turn.",
|
|
819
|
+
),
|
|
820
|
+
) -> None:
|
|
821
|
+
# The group-level copies of the prompt-only flags exist so Click parses
|
|
822
|
+
# `lisa -r "fix it"` as [group flags, prompt] instead of dying on an
|
|
823
|
+
# unknown option; the values ride on this module-level slot for the
|
|
824
|
+
# prompt command to OR in (Typer commands cannot read click's
|
|
825
|
+
# thread-local context — see the hoist comment in ``_prompt_command``).
|
|
826
|
+
# They are hidden (not documented) — the documented home for these flags
|
|
827
|
+
# is `lisa prompt`.
|
|
828
|
+
_GROUP_HOIST.update(raw=raw, quiet=quiet, no_stdin=no_stdin)
|
|
829
|
+
ctx.obj = {"raw": raw, "quiet": quiet, "no_stdin": no_stdin}
|
|
830
|
+
if version:
|
|
831
|
+
console.print(f"lisa v{_get_version()}")
|
|
832
|
+
raise typer.Exit()
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# ---------------------------------------------------------------------------
|
|
836
|
+
# verify command
|
|
837
|
+
# ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
_VERIFY_PASS = "[green]PASS[/green]"
|
|
840
|
+
_VERIFY_WARN = "[yellow]WARN[/yellow]"
|
|
841
|
+
_VERIFY_FAIL = "[red]FAIL[/red]"
|
|
842
|
+
_KEY_REDACT_VISIBLE_CHARS = 4
|
|
843
|
+
_MIN_PYTHON_MAJOR = 3
|
|
844
|
+
_MIN_PYTHON_MINOR = 11
|
|
845
|
+
_HTTP_OK = 200
|
|
846
|
+
_OLLAMA_MODEL_PREVIEW_LIMIT = 5
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _redact_key(value: str) -> str:
|
|
850
|
+
if not value:
|
|
851
|
+
return "(not set)"
|
|
852
|
+
if len(value) <= _KEY_REDACT_VISIBLE_CHARS:
|
|
853
|
+
return "*" * len(value)
|
|
854
|
+
return "*" * (len(value) - _KEY_REDACT_VISIBLE_CHARS) + value[-_KEY_REDACT_VISIBLE_CHARS:]
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _verify_check_python() -> tuple[str, str]:
|
|
858
|
+
version = platform.python_version()
|
|
859
|
+
major, minor, *_ = version.split(".")
|
|
860
|
+
if int(major) >= _MIN_PYTHON_MAJOR and int(minor) >= _MIN_PYTHON_MINOR:
|
|
861
|
+
return _VERIFY_PASS, f"Python {version}"
|
|
862
|
+
return _VERIFY_FAIL, f"Python {version} (need >= 3.11)"
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _verify_check_env(runtime: HostRuntime) -> tuple[str, str]:
|
|
866
|
+
# Check for .env in the same search order as the startup loader. The
|
|
867
|
+
# loader's _ENV_SEARCH_CANDIDATES is module-level so it can be
|
|
868
|
+
# monkeypatched (tests); CWD still resolves dynamically because pytest's
|
|
869
|
+
# chdir affects it.
|
|
870
|
+
for candidate in _ENV_SEARCH_CANDIDATES:
|
|
871
|
+
if candidate.exists():
|
|
872
|
+
return _VERIFY_PASS, f".env found ({candidate})"
|
|
873
|
+
return _VERIFY_WARN, ".env not found. Create one at ~/.lisa/.env with your model provider keys."
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _verify_check_manifest(runtime: HostRuntime) -> tuple[str, str]:
|
|
877
|
+
from runtime.model_manifest import load_model_manifest
|
|
878
|
+
|
|
879
|
+
try:
|
|
880
|
+
path = runtime.paths.models_path
|
|
881
|
+
manifest = load_model_manifest(path)
|
|
882
|
+
model_count = len(manifest.models)
|
|
883
|
+
if model_count == 0:
|
|
884
|
+
return _VERIFY_FAIL, "Model manifest loaded but contains zero models"
|
|
885
|
+
model_ids = ", ".join(m.model_id for m in manifest.models)
|
|
886
|
+
return _VERIFY_PASS, f"{model_count} model(s): {model_ids}"
|
|
887
|
+
except Exception as exc:
|
|
888
|
+
return _VERIFY_FAIL, f"Model manifest failed to load: {exc}"
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _verify_check_pack_catalog() -> tuple[str, str]:
|
|
892
|
+
try:
|
|
893
|
+
from kernel.ipsar_fsm import get_workflow_catalog
|
|
894
|
+
|
|
895
|
+
catalog = get_workflow_catalog()
|
|
896
|
+
mode_count = len(catalog)
|
|
897
|
+
if mode_count == 0:
|
|
898
|
+
return _VERIFY_WARN, "Pack catalog loaded but contains zero modes"
|
|
899
|
+
mode_ids = ", ".join(sorted(catalog.keys()))
|
|
900
|
+
return _VERIFY_PASS, f"{mode_count} mode(s): {mode_ids}"
|
|
901
|
+
except Exception as exc:
|
|
902
|
+
return _VERIFY_FAIL, f"Pack catalog failed to load: {exc}"
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _verify_check_ollama() -> tuple[str, str]:
|
|
906
|
+
import os
|
|
907
|
+
|
|
908
|
+
import httpx
|
|
909
|
+
|
|
910
|
+
configured_host = (
|
|
911
|
+
os.getenv("LISA_OLLAMA_HOST", "").strip()
|
|
912
|
+
or os.getenv("OLLAMA_HOST", "").strip()
|
|
913
|
+
or "127.0.0.1:11434"
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
async def _ping() -> tuple[str, str]:
|
|
917
|
+
try:
|
|
918
|
+
base_url = f"http://{configured_host}"
|
|
919
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
920
|
+
resp = await client.get(f"{base_url}/api/tags")
|
|
921
|
+
if resp.status_code == _HTTP_OK:
|
|
922
|
+
data = resp.json()
|
|
923
|
+
models = [m.get("name", "?") for m in data.get("models", [])]
|
|
924
|
+
if models:
|
|
925
|
+
preview = ", ".join(models[:_OLLAMA_MODEL_PREVIEW_LIMIT])
|
|
926
|
+
suffix = "..." if len(models) > _OLLAMA_MODEL_PREVIEW_LIMIT else ""
|
|
927
|
+
return _VERIFY_PASS, (
|
|
928
|
+
f"Ollama reachable, {len(models)} model(s) pulled: {preview}{suffix}"
|
|
929
|
+
)
|
|
930
|
+
return _VERIFY_WARN, "Ollama reachable but no models pulled"
|
|
931
|
+
return _VERIFY_WARN, f"Ollama returned HTTP {resp.status_code}"
|
|
932
|
+
except Exception as exc:
|
|
933
|
+
return _VERIFY_WARN, f"Ollama not reachable at {configured_host}: {exc}"
|
|
934
|
+
|
|
935
|
+
try:
|
|
936
|
+
return asyncio.run(_ping())
|
|
937
|
+
except Exception as exc:
|
|
938
|
+
return _VERIFY_WARN, f"Ollama check failed: {exc}"
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _verify_check_provider_keys() -> tuple[str, str]:
|
|
942
|
+
import os
|
|
943
|
+
|
|
944
|
+
parts: list[str] = []
|
|
945
|
+
# Check LISA_* vars first (the new host's env vars), then fall back to
|
|
946
|
+
# the bare provider variables accepted by the host gateway provider.
|
|
947
|
+
azure_key = (
|
|
948
|
+
os.getenv("LISA_AZURE_OPENAI_API_KEY", "").strip()
|
|
949
|
+
or os.getenv("AZURE_OPENAI_API_KEY", "").strip()
|
|
950
|
+
)
|
|
951
|
+
azure_endpoint = (
|
|
952
|
+
os.getenv("LISA_AZURE_OPENAI_ENDPOINT", "").strip()
|
|
953
|
+
or os.getenv("AZURE_OPENAI_ENDPOINT", "").strip()
|
|
954
|
+
)
|
|
955
|
+
if azure_key and azure_endpoint:
|
|
956
|
+
parts.append(f"Azure OpenAI: {_redact_key(azure_key)} @ {azure_endpoint}")
|
|
957
|
+
elif azure_key or azure_endpoint:
|
|
958
|
+
parts.append("Azure OpenAI: partially configured")
|
|
959
|
+
|
|
960
|
+
for provider, variable in (
|
|
961
|
+
("OpenAI", "OPENAI_API_KEY"),
|
|
962
|
+
("Anthropic", "ANTHROPIC_API_KEY"),
|
|
963
|
+
("Google Gemini", "GOOGLE_API_KEY"),
|
|
964
|
+
):
|
|
965
|
+
key = os.getenv(f"LISA_{variable}", "").strip() or os.getenv(variable, "").strip()
|
|
966
|
+
if key:
|
|
967
|
+
parts.append(f"{provider}: {_redact_key(key)}")
|
|
968
|
+
|
|
969
|
+
ollama_host = os.getenv("LISA_OLLAMA_HOST", "").strip() or os.getenv("OLLAMA_HOST", "").strip()
|
|
970
|
+
if ollama_host:
|
|
971
|
+
parts.append(f"Ollama: {ollama_host}")
|
|
972
|
+
|
|
973
|
+
openrouter_key = (
|
|
974
|
+
os.getenv("LISA_OPENROUTER_API_KEY", "").strip()
|
|
975
|
+
or os.getenv("OPENROUTER_API_KEY", "").strip()
|
|
976
|
+
)
|
|
977
|
+
if openrouter_key:
|
|
978
|
+
parts.append(f"OpenRouter: {_redact_key(openrouter_key)}")
|
|
979
|
+
|
|
980
|
+
aws_access_key = (
|
|
981
|
+
os.getenv("LISA_AWS_ACCESS_KEY_ID", "").strip()
|
|
982
|
+
or os.getenv("AWS_ACCESS_KEY_ID", "").strip()
|
|
983
|
+
)
|
|
984
|
+
aws_secret_key = (
|
|
985
|
+
os.getenv("LISA_AWS_SECRET_ACCESS_KEY", "").strip()
|
|
986
|
+
or os.getenv("AWS_SECRET_ACCESS_KEY", "").strip()
|
|
987
|
+
)
|
|
988
|
+
aws_region = (
|
|
989
|
+
os.getenv("LISA_AWS_REGION", "").strip()
|
|
990
|
+
or os.getenv("AWS_REGION", "").strip()
|
|
991
|
+
or os.getenv("AWS_DEFAULT_REGION", "").strip()
|
|
992
|
+
)
|
|
993
|
+
if aws_access_key and aws_secret_key and aws_region:
|
|
994
|
+
parts.append(f"AWS Bedrock: {_redact_key(aws_access_key)} @ {aws_region}")
|
|
995
|
+
elif aws_access_key or aws_secret_key or aws_region:
|
|
996
|
+
parts.append("AWS Bedrock: partially configured")
|
|
997
|
+
|
|
998
|
+
if not parts:
|
|
999
|
+
return _VERIFY_WARN, "No provider keys configured"
|
|
1000
|
+
return _VERIFY_PASS, "; ".join(parts)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _verify_check_paths(runtime: HostRuntime) -> tuple[str, str]:
|
|
1004
|
+
parts: list[str] = []
|
|
1005
|
+
parts.append(f"Data dir: {runtime.paths.data_dir}")
|
|
1006
|
+
parts.append(f"Thread logs: {runtime.paths.thread_dir}")
|
|
1007
|
+
tp = runtime.paths.thread_path
|
|
1008
|
+
if tp:
|
|
1009
|
+
parts.append(f"Thread path: {tp}")
|
|
1010
|
+
return _VERIFY_PASS, "; ".join(parts)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _verify_check_session_context(runtime: HostRuntime) -> tuple[str, str]:
|
|
1014
|
+
if not runtime.config.user_slug:
|
|
1015
|
+
return "[yellow]NONE[/yellow]", "No session context active"
|
|
1016
|
+
parts = [f"Context: {runtime.config.user_slug}"]
|
|
1017
|
+
ref = runtime.config.session_context.strip()
|
|
1018
|
+
if ref and ref != runtime.config.user_slug:
|
|
1019
|
+
parts.append(f"Path: {ref}")
|
|
1020
|
+
return _VERIFY_PASS, "; ".join(parts)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
@app.command("verify", help="Run verification checks of the Lisa environment.")
|
|
1024
|
+
def _verify_command(
|
|
1025
|
+
log_level: LogLevelOption = "",
|
|
1026
|
+
log: LogOption = "",
|
|
1027
|
+
no_log: NoLogOption = False,
|
|
1028
|
+
data_dir: DataDirOption = "",
|
|
1029
|
+
profile: ProfileOption = "",
|
|
1030
|
+
thread_dir: ThreadDirOption = "",
|
|
1031
|
+
no_thread: NoThreadOption = False,
|
|
1032
|
+
session_context: SessionContextOption = "",
|
|
1033
|
+
timezone: TimezoneOption = "",
|
|
1034
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1035
|
+
streaming: StreamingOption = "",
|
|
1036
|
+
host_log: HostLogOption = "",
|
|
1037
|
+
) -> None:
|
|
1038
|
+
"""Run verification checks of the Lisa environment."""
|
|
1039
|
+
runtime = _initialize_runtime_or_exit(
|
|
1040
|
+
log_level=log_level,
|
|
1041
|
+
log_path=log,
|
|
1042
|
+
no_log=no_log,
|
|
1043
|
+
data_dir=data_dir,
|
|
1044
|
+
profile=profile,
|
|
1045
|
+
thread_dir=thread_dir,
|
|
1046
|
+
no_thread=no_thread,
|
|
1047
|
+
session_context=session_context,
|
|
1048
|
+
timezone=timezone,
|
|
1049
|
+
state_snapshot_path=state_snapshot,
|
|
1050
|
+
streaming=streaming,
|
|
1051
|
+
host_log_path=host_log,
|
|
1052
|
+
)
|
|
1053
|
+
if runtime is None:
|
|
1054
|
+
return
|
|
1055
|
+
checks: list[tuple[str, str, str]] = [
|
|
1056
|
+
("Python", *_verify_check_python()),
|
|
1057
|
+
(".env", *_verify_check_env(runtime)),
|
|
1058
|
+
("Session context", *_verify_check_session_context(runtime)),
|
|
1059
|
+
("Model manifest", *_verify_check_manifest(runtime)),
|
|
1060
|
+
("Pack catalog", *_verify_check_pack_catalog()),
|
|
1061
|
+
("Provider keys", *_verify_check_provider_keys()),
|
|
1062
|
+
("Ollama", *_verify_check_ollama()),
|
|
1063
|
+
("Paths", *_verify_check_paths(runtime)),
|
|
1064
|
+
]
|
|
1065
|
+
|
|
1066
|
+
from rich.box import SIMPLE_HEAD
|
|
1067
|
+
|
|
1068
|
+
# No zebra row_styles here either: terminal backgrounds are unknown and
|
|
1069
|
+
# dark-grey rows vanish on black consoles (same rationale as
|
|
1070
|
+
# render._styled_table).
|
|
1071
|
+
table = Table(
|
|
1072
|
+
title="lisa verify",
|
|
1073
|
+
box=SIMPLE_HEAD,
|
|
1074
|
+
header_style="bold steel_blue3",
|
|
1075
|
+
)
|
|
1076
|
+
table.add_column("Check", style="bold")
|
|
1077
|
+
table.add_column("Status")
|
|
1078
|
+
table.add_column("Detail", overflow="fold", no_wrap=False)
|
|
1079
|
+
|
|
1080
|
+
failures = 0
|
|
1081
|
+
warnings = 0
|
|
1082
|
+
for name, status, detail in checks:
|
|
1083
|
+
table.add_row(name, status, detail)
|
|
1084
|
+
if "FAIL" in status:
|
|
1085
|
+
failures += 1
|
|
1086
|
+
elif "WARN" in status:
|
|
1087
|
+
warnings += 1
|
|
1088
|
+
|
|
1089
|
+
console.print(table)
|
|
1090
|
+
|
|
1091
|
+
if failures:
|
|
1092
|
+
console.print(f"\n[red]{failures} check(s) failed.[/red]")
|
|
1093
|
+
raise typer.Exit(code=1)
|
|
1094
|
+
if warnings:
|
|
1095
|
+
console.print(f"\n[yellow]All mandatory checks passed ({warnings} warnings).[/yellow]")
|
|
1096
|
+
else:
|
|
1097
|
+
console.print("\n[green]All checks passed.[/green]")
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
# ---------------------------------------------------------------------------
|
|
1101
|
+
# modes command
|
|
1102
|
+
# ---------------------------------------------------------------------------
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
@app.command("modes", help="List supported Lisa modes.")
|
|
1106
|
+
def _modes_command(
|
|
1107
|
+
log_level: LogLevelOption = "",
|
|
1108
|
+
log: LogOption = "",
|
|
1109
|
+
no_log: NoLogOption = False,
|
|
1110
|
+
data_dir: DataDirOption = "",
|
|
1111
|
+
profile: ProfileOption = "",
|
|
1112
|
+
thread_dir: ThreadDirOption = "",
|
|
1113
|
+
no_thread: NoThreadOption = False,
|
|
1114
|
+
session_context: SessionContextOption = "",
|
|
1115
|
+
timezone: TimezoneOption = "",
|
|
1116
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1117
|
+
streaming: StreamingOption = "",
|
|
1118
|
+
host_log: HostLogOption = "",
|
|
1119
|
+
) -> None:
|
|
1120
|
+
runtime = _initialize_runtime_or_exit(
|
|
1121
|
+
log_level=log_level,
|
|
1122
|
+
log_path=log,
|
|
1123
|
+
no_log=no_log,
|
|
1124
|
+
data_dir=data_dir,
|
|
1125
|
+
profile=profile,
|
|
1126
|
+
thread_dir=thread_dir,
|
|
1127
|
+
no_thread=no_thread,
|
|
1128
|
+
session_context=session_context,
|
|
1129
|
+
timezone=timezone,
|
|
1130
|
+
state_snapshot_path=state_snapshot,
|
|
1131
|
+
streaming=streaming,
|
|
1132
|
+
host_log_path=host_log,
|
|
1133
|
+
)
|
|
1134
|
+
if runtime is None:
|
|
1135
|
+
return
|
|
1136
|
+
render_modes(runtime, console=console)
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
# ---------------------------------------------------------------------------
|
|
1140
|
+
# models command
|
|
1141
|
+
# ---------------------------------------------------------------------------
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
@app.command(
|
|
1145
|
+
"models",
|
|
1146
|
+
help=(
|
|
1147
|
+
"Manage the model manifest. ACTION defaults to 'show'. "
|
|
1148
|
+
"Subcommands: show, discover, apply, help. "
|
|
1149
|
+
"(reload and edit only refresh transient state or print guidance; "
|
|
1150
|
+
"they have no durable effect as a one-shot CLI invocation.)"
|
|
1151
|
+
),
|
|
1152
|
+
)
|
|
1153
|
+
def _models_command(
|
|
1154
|
+
action: str = typer.Argument(
|
|
1155
|
+
"show",
|
|
1156
|
+
help=(
|
|
1157
|
+
"Action: show (default), discover, apply, help. "
|
|
1158
|
+
"'discover' writes a proposal manifest; 'apply' promotes it to active."
|
|
1159
|
+
),
|
|
1160
|
+
),
|
|
1161
|
+
force: bool = typer.Option(
|
|
1162
|
+
False,
|
|
1163
|
+
"--force",
|
|
1164
|
+
help="With 'discover', overwrite an existing proposal manifest.",
|
|
1165
|
+
),
|
|
1166
|
+
log_level: LogLevelOption = "",
|
|
1167
|
+
log: LogOption = "",
|
|
1168
|
+
no_log: NoLogOption = False,
|
|
1169
|
+
data_dir: DataDirOption = "",
|
|
1170
|
+
profile: ProfileOption = "",
|
|
1171
|
+
thread_dir: ThreadDirOption = "",
|
|
1172
|
+
no_thread: NoThreadOption = False,
|
|
1173
|
+
session_context: SessionContextOption = "",
|
|
1174
|
+
timezone: TimezoneOption = "",
|
|
1175
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1176
|
+
streaming: StreamingOption = "",
|
|
1177
|
+
host_log: HostLogOption = "",
|
|
1178
|
+
) -> None:
|
|
1179
|
+
runtime = _initialize_runtime_or_exit(
|
|
1180
|
+
log_level=log_level,
|
|
1181
|
+
log_path=log,
|
|
1182
|
+
no_log=no_log,
|
|
1183
|
+
data_dir=data_dir,
|
|
1184
|
+
profile=profile,
|
|
1185
|
+
thread_dir=thread_dir,
|
|
1186
|
+
no_thread=no_thread,
|
|
1187
|
+
session_context=session_context,
|
|
1188
|
+
timezone=timezone,
|
|
1189
|
+
state_snapshot_path=state_snapshot,
|
|
1190
|
+
streaming=streaming,
|
|
1191
|
+
host_log_path=host_log,
|
|
1192
|
+
)
|
|
1193
|
+
if runtime is None:
|
|
1194
|
+
return
|
|
1195
|
+
|
|
1196
|
+
normalized = action.strip().lower()
|
|
1197
|
+
|
|
1198
|
+
# 'show' uses the Rich table renderer; on failure (e.g. no active
|
|
1199
|
+
# manifest yet) it falls back to the core text renderer so the user
|
|
1200
|
+
# gets the same friendly "run discover first" guidance as the REPL.
|
|
1201
|
+
if normalized in {"", "show"}:
|
|
1202
|
+
try:
|
|
1203
|
+
render_models(runtime, console=console)
|
|
1204
|
+
return
|
|
1205
|
+
except Exception: # pylint: disable=broad-except
|
|
1206
|
+
normalized = "show"
|
|
1207
|
+
|
|
1208
|
+
# 'reload' and 'edit' only mutate transient in-process state or print
|
|
1209
|
+
# guidance — both pointless for a one-shot CLI process whose runtime
|
|
1210
|
+
# is discarded on exit. Surface that clearly instead of pretending
|
|
1211
|
+
# to do work; the durable CLI workflow is discover -> (edit file) ->
|
|
1212
|
+
# apply. Both remain available in the persistent REPL.
|
|
1213
|
+
if normalized in {"reload", "edit"}:
|
|
1214
|
+
console.print(
|
|
1215
|
+
"[yellow]'models " + normalized + "' has no durable effect as a one-shot "
|
|
1216
|
+
"CLI command: it only refreshes in-process state or prints guidance, and "
|
|
1217
|
+
"the CLI runtime is discarded on exit.[/yellow]\n"
|
|
1218
|
+
"Edit models.proposed.yaml by hand, then run 'lisa models apply'."
|
|
1219
|
+
)
|
|
1220
|
+
return
|
|
1221
|
+
|
|
1222
|
+
# show (fallback), discover, apply, help delegate to the
|
|
1223
|
+
# transport-neutral core handler (the same one the REPL /models
|
|
1224
|
+
# command uses), so behavior stays identical across hosts. We
|
|
1225
|
+
# synthesize the equivalent '/models <sub> [--force]' slash string
|
|
1226
|
+
# and print the returned Markdown text in a Rich panel.
|
|
1227
|
+
from rich.markdown import Markdown
|
|
1228
|
+
from runtime.commands import handle_models_command
|
|
1229
|
+
|
|
1230
|
+
if normalized == "discover" and force:
|
|
1231
|
+
stripped = "/models discover --force"
|
|
1232
|
+
else:
|
|
1233
|
+
stripped = f"/models {normalized}".strip()
|
|
1234
|
+
|
|
1235
|
+
# The discover/apply/reload/show subcommands read or write model
|
|
1236
|
+
# manifests via the lisa-storage MCP server, which requires a live
|
|
1237
|
+
# MCP tool pool. _initialize_runtime only compiles configs; the pool
|
|
1238
|
+
# is started inside the host's event loop. Start it here, run the
|
|
1239
|
+
# handler, and tear the pool down in finally — all within one
|
|
1240
|
+
# asyncio.run loop so anyio cancel scopes are entered and exited in
|
|
1241
|
+
# the same task (per runtime.runtime invariants).
|
|
1242
|
+
async def _run_models_command() -> str:
|
|
1243
|
+
try:
|
|
1244
|
+
await runtime.start()
|
|
1245
|
+
return await handle_models_command(runtime, stripped, stripped)
|
|
1246
|
+
finally:
|
|
1247
|
+
await runtime.aclose()
|
|
1248
|
+
|
|
1249
|
+
response_text = asyncio.run(_run_models_command())
|
|
1250
|
+
console.print(Panel(Markdown(response_text), title="Models", border_style="cyan"))
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
# ---------------------------------------------------------------------------
|
|
1254
|
+
# operations command
|
|
1255
|
+
# ---------------------------------------------------------------------------
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
@app.command("operations", help="List internal operations with IDs for allowlist authoring.")
|
|
1259
|
+
def _operations_command(
|
|
1260
|
+
log_level: LogLevelOption = "",
|
|
1261
|
+
log: LogOption = "",
|
|
1262
|
+
no_log: NoLogOption = False,
|
|
1263
|
+
data_dir: DataDirOption = "",
|
|
1264
|
+
profile: ProfileOption = "",
|
|
1265
|
+
thread_dir: ThreadDirOption = "",
|
|
1266
|
+
no_thread: NoThreadOption = False,
|
|
1267
|
+
session_context: SessionContextOption = "",
|
|
1268
|
+
timezone: TimezoneOption = "",
|
|
1269
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1270
|
+
streaming: StreamingOption = "",
|
|
1271
|
+
host_log: HostLogOption = "",
|
|
1272
|
+
) -> None:
|
|
1273
|
+
runtime = _initialize_runtime_or_exit(
|
|
1274
|
+
log_level=log_level,
|
|
1275
|
+
log_path=log,
|
|
1276
|
+
no_log=no_log,
|
|
1277
|
+
data_dir=data_dir,
|
|
1278
|
+
profile=profile,
|
|
1279
|
+
thread_dir=thread_dir,
|
|
1280
|
+
no_thread=no_thread,
|
|
1281
|
+
session_context=session_context,
|
|
1282
|
+
timezone=timezone,
|
|
1283
|
+
state_snapshot_path=state_snapshot,
|
|
1284
|
+
streaming=streaming,
|
|
1285
|
+
host_log_path=host_log,
|
|
1286
|
+
)
|
|
1287
|
+
if runtime is None:
|
|
1288
|
+
return
|
|
1289
|
+
render_operations(runtime, console=console)
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
# ---------------------------------------------------------------------------
|
|
1293
|
+
# tools command
|
|
1294
|
+
# ---------------------------------------------------------------------------
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
@app.command(
|
|
1298
|
+
"tools",
|
|
1299
|
+
help=(
|
|
1300
|
+
"List internal tools with IDs for allowlist authoring. "
|
|
1301
|
+
"Use the `mcp` command to inspect MCP server tools."
|
|
1302
|
+
),
|
|
1303
|
+
)
|
|
1304
|
+
def _tools_command(
|
|
1305
|
+
log_level: LogLevelOption = "",
|
|
1306
|
+
log: LogOption = "",
|
|
1307
|
+
no_log: NoLogOption = False,
|
|
1308
|
+
data_dir: DataDirOption = "",
|
|
1309
|
+
profile: ProfileOption = "",
|
|
1310
|
+
thread_dir: ThreadDirOption = "",
|
|
1311
|
+
no_thread: NoThreadOption = False,
|
|
1312
|
+
session_context: SessionContextOption = "",
|
|
1313
|
+
timezone: TimezoneOption = "",
|
|
1314
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1315
|
+
streaming: StreamingOption = "",
|
|
1316
|
+
host_log: HostLogOption = "",
|
|
1317
|
+
) -> None:
|
|
1318
|
+
runtime = _initialize_runtime_or_exit(
|
|
1319
|
+
log_level=log_level,
|
|
1320
|
+
log_path=log,
|
|
1321
|
+
no_log=no_log,
|
|
1322
|
+
data_dir=data_dir,
|
|
1323
|
+
profile=profile,
|
|
1324
|
+
thread_dir=thread_dir,
|
|
1325
|
+
no_thread=no_thread,
|
|
1326
|
+
session_context=session_context,
|
|
1327
|
+
timezone=timezone,
|
|
1328
|
+
state_snapshot_path=state_snapshot,
|
|
1329
|
+
streaming=streaming,
|
|
1330
|
+
host_log_path=host_log,
|
|
1331
|
+
)
|
|
1332
|
+
if runtime is None:
|
|
1333
|
+
return
|
|
1334
|
+
render_tools(runtime, console=console)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
# ---------------------------------------------------------------------------
|
|
1338
|
+
# last command
|
|
1339
|
+
# ---------------------------------------------------------------------------
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
@app.command(
|
|
1343
|
+
"last",
|
|
1344
|
+
help=(
|
|
1345
|
+
"Reprint a previous response verbatim (default: the last model turn). "
|
|
1346
|
+
"Pass a turn number to replay that turn's response: `lisa last 3`. "
|
|
1347
|
+
"Reads the durable thread — no model call."
|
|
1348
|
+
),
|
|
1349
|
+
)
|
|
1350
|
+
def _last_command(
|
|
1351
|
+
turn: str = typer.Argument(
|
|
1352
|
+
"",
|
|
1353
|
+
help="Optional turn number to replay that turn's response.",
|
|
1354
|
+
),
|
|
1355
|
+
log_level: LogLevelOption = "",
|
|
1356
|
+
log: LogOption = "",
|
|
1357
|
+
no_log: NoLogOption = False,
|
|
1358
|
+
data_dir: DataDirOption = "",
|
|
1359
|
+
profile: ProfileOption = "",
|
|
1360
|
+
thread_dir: ThreadDirOption = "",
|
|
1361
|
+
no_thread: NoThreadOption = False,
|
|
1362
|
+
session_context: SessionContextOption = "",
|
|
1363
|
+
timezone: TimezoneOption = "",
|
|
1364
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1365
|
+
streaming: StreamingOption = "",
|
|
1366
|
+
host_log: HostLogOption = "",
|
|
1367
|
+
) -> None:
|
|
1368
|
+
runtime = _initialize_runtime_or_exit(
|
|
1369
|
+
log_level=log_level,
|
|
1370
|
+
log_path=log,
|
|
1371
|
+
no_log=no_log,
|
|
1372
|
+
data_dir=data_dir,
|
|
1373
|
+
profile=profile,
|
|
1374
|
+
thread_dir=thread_dir,
|
|
1375
|
+
no_thread=no_thread,
|
|
1376
|
+
session_context=session_context,
|
|
1377
|
+
timezone=timezone,
|
|
1378
|
+
state_snapshot_path=state_snapshot,
|
|
1379
|
+
streaming=streaming,
|
|
1380
|
+
host_log_path=host_log,
|
|
1381
|
+
)
|
|
1382
|
+
if runtime is None:
|
|
1383
|
+
return
|
|
1384
|
+
from rich.markdown import Markdown # lazy: defers startup cost
|
|
1385
|
+
from rich.rule import Rule # lazy: defers startup cost
|
|
1386
|
+
from runtime.commands import ( # lazy: defers runtime.commands startup cost
|
|
1387
|
+
resolve_last_turn,
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
turn_index, response_text = resolve_last_turn(runtime, turn.strip())
|
|
1391
|
+
if turn_index is not None and not console.is_terminal:
|
|
1392
|
+
# Piped/redirected: a one-line header so the replayed turn is
|
|
1393
|
+
# addressable in captured output, then the response verbatim —
|
|
1394
|
+
# no Markdown re-render (the raw contract; highlight/markup off
|
|
1395
|
+
# so Rich never interprets the body). Diagnostics (usage,
|
|
1396
|
+
# not-found, friendly messages) print bare as before.
|
|
1397
|
+
console.print(f"[ turn {turn_index} ]", style=_STYLE_RESPONSE, markup=False)
|
|
1398
|
+
console.print(response_text, highlight=False, markup=False, soft_wrap=False)
|
|
1399
|
+
return
|
|
1400
|
+
if not console.is_terminal:
|
|
1401
|
+
console.print(response_text, highlight=False, markup=False, soft_wrap=False)
|
|
1402
|
+
return
|
|
1403
|
+
# Interactive: the same treatment the original answer gets — a
|
|
1404
|
+
# ``Response · turn N`` rule separator plus Rich Markdown rendering
|
|
1405
|
+
# (colors, headings, code fences). The number is the global thread
|
|
1406
|
+
# turn index the prompt output already renders, so operators address
|
|
1407
|
+
# a replayed turn the same way wherever it surfaces.
|
|
1408
|
+
console.print(Rule(title=f"Response · turn {turn_index}", style=_STYLE_RESPONSE))
|
|
1409
|
+
console.print(Markdown(response_text))
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
# ---------------------------------------------------------------------------
|
|
1413
|
+
# settings / tokens / debug — constant host-command verbs
|
|
1414
|
+
# ---------------------------------------------------------------------------
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _render_markdown_response(response_text: str, *, title: str) -> None:
|
|
1418
|
+
"""Print a transport-neutral host-command reply as a Rich panel.
|
|
1419
|
+
|
|
1420
|
+
Non-tty output (pipes, redirects, CI) prints the raw text verbatim —
|
|
1421
|
+
no Markdown re-render — so piped output stays machine-readable (the
|
|
1422
|
+
same contract as PromptTransport raw mode).
|
|
1423
|
+
"""
|
|
1424
|
+
if not console.is_terminal:
|
|
1425
|
+
console.print(response_text, highlight=False, markup=False, soft_wrap=False)
|
|
1426
|
+
return
|
|
1427
|
+
from rich.markdown import Markdown # lazy: defers startup cost
|
|
1428
|
+
|
|
1429
|
+
console.print(Panel(Markdown(response_text), title=title, border_style="cyan"))
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
@app.command("settings", help="Show the effective runtime settings (one-shot /settings).")
|
|
1433
|
+
def _settings_command( # Typer exposes shared runtime options as CLI flags.
|
|
1434
|
+
log_level: LogLevelOption = "",
|
|
1435
|
+
log: LogOption = "",
|
|
1436
|
+
no_log: NoLogOption = False,
|
|
1437
|
+
data_dir: DataDirOption = "",
|
|
1438
|
+
profile: ProfileOption = "",
|
|
1439
|
+
overlays_dir: OverlaysDirOption = "",
|
|
1440
|
+
thread_dir: ThreadDirOption = "",
|
|
1441
|
+
no_thread: NoThreadOption = False,
|
|
1442
|
+
session_context: SessionContextOption = "",
|
|
1443
|
+
timezone: TimezoneOption = "",
|
|
1444
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1445
|
+
streaming: StreamingOption = "",
|
|
1446
|
+
host_log: HostLogOption = "",
|
|
1447
|
+
) -> None:
|
|
1448
|
+
"""Render the effective runtime settings without a model turn."""
|
|
1449
|
+
_dispatch_constant_command(
|
|
1450
|
+
"/settings",
|
|
1451
|
+
title="Settings",
|
|
1452
|
+
log_level=log_level,
|
|
1453
|
+
log_path=log,
|
|
1454
|
+
no_log=no_log,
|
|
1455
|
+
data_dir=data_dir,
|
|
1456
|
+
profile=profile,
|
|
1457
|
+
overlays_dir=overlays_dir,
|
|
1458
|
+
thread_dir=thread_dir,
|
|
1459
|
+
no_thread=no_thread,
|
|
1460
|
+
session_context=session_context,
|
|
1461
|
+
timezone=timezone,
|
|
1462
|
+
state_snapshot_path=state_snapshot,
|
|
1463
|
+
streaming=streaming,
|
|
1464
|
+
host_log_path=host_log,
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
@app.command("tokens", help="Show session token usage and budget (one-shot /tokens).")
|
|
1469
|
+
def _tokens_command( # Typer exposes shared runtime options as CLI flags.
|
|
1470
|
+
log_level: LogLevelOption = "",
|
|
1471
|
+
log: LogOption = "",
|
|
1472
|
+
no_log: NoLogOption = False,
|
|
1473
|
+
data_dir: DataDirOption = "",
|
|
1474
|
+
profile: ProfileOption = "",
|
|
1475
|
+
overlays_dir: OverlaysDirOption = "",
|
|
1476
|
+
thread_dir: ThreadDirOption = "",
|
|
1477
|
+
no_thread: NoThreadOption = False,
|
|
1478
|
+
session_context: SessionContextOption = "",
|
|
1479
|
+
timezone: TimezoneOption = "",
|
|
1480
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1481
|
+
streaming: StreamingOption = "",
|
|
1482
|
+
host_log: HostLogOption = "",
|
|
1483
|
+
) -> None:
|
|
1484
|
+
"""Render token accounting without a model turn."""
|
|
1485
|
+
_dispatch_constant_command(
|
|
1486
|
+
"/tokens",
|
|
1487
|
+
title="Tokens",
|
|
1488
|
+
log_level=log_level,
|
|
1489
|
+
log_path=log,
|
|
1490
|
+
no_log=no_log,
|
|
1491
|
+
data_dir=data_dir,
|
|
1492
|
+
profile=profile,
|
|
1493
|
+
overlays_dir=overlays_dir,
|
|
1494
|
+
thread_dir=thread_dir,
|
|
1495
|
+
no_thread=no_thread,
|
|
1496
|
+
session_context=session_context,
|
|
1497
|
+
timezone=timezone,
|
|
1498
|
+
state_snapshot_path=state_snapshot,
|
|
1499
|
+
streaming=streaming,
|
|
1500
|
+
host_log_path=host_log,
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
@app.command(
|
|
1505
|
+
"debug",
|
|
1506
|
+
help=(
|
|
1507
|
+
"Show the complete, unedited model input for a previous turn "
|
|
1508
|
+
"(default: the last turn): `lisa debug [N]`."
|
|
1509
|
+
),
|
|
1510
|
+
)
|
|
1511
|
+
def _debug_command( # Typer exposes shared runtime options as CLI flags.
|
|
1512
|
+
turn: str = typer.Argument(
|
|
1513
|
+
"",
|
|
1514
|
+
help="Optional turn number whose model input to render.",
|
|
1515
|
+
),
|
|
1516
|
+
log_level: LogLevelOption = "",
|
|
1517
|
+
log: LogOption = "",
|
|
1518
|
+
no_log: NoLogOption = False,
|
|
1519
|
+
data_dir: DataDirOption = "",
|
|
1520
|
+
profile: ProfileOption = "",
|
|
1521
|
+
overlays_dir: OverlaysDirOption = "",
|
|
1522
|
+
thread_dir: ThreadDirOption = "",
|
|
1523
|
+
no_thread: NoThreadOption = False,
|
|
1524
|
+
session_context: SessionContextOption = "",
|
|
1525
|
+
timezone: TimezoneOption = "",
|
|
1526
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1527
|
+
streaming: StreamingOption = "",
|
|
1528
|
+
host_log: HostLogOption = "",
|
|
1529
|
+
) -> None:
|
|
1530
|
+
"""Render one turn's complete model input without a model turn."""
|
|
1531
|
+
_dispatch_constant_command(
|
|
1532
|
+
f"/debug {turn.strip()}".strip(),
|
|
1533
|
+
title="Debug",
|
|
1534
|
+
log_level=log_level,
|
|
1535
|
+
log_path=log,
|
|
1536
|
+
no_log=no_log,
|
|
1537
|
+
data_dir=data_dir,
|
|
1538
|
+
profile=profile,
|
|
1539
|
+
overlays_dir=overlays_dir,
|
|
1540
|
+
thread_dir=thread_dir,
|
|
1541
|
+
no_thread=no_thread,
|
|
1542
|
+
session_context=session_context,
|
|
1543
|
+
timezone=timezone,
|
|
1544
|
+
state_snapshot_path=state_snapshot,
|
|
1545
|
+
streaming=streaming,
|
|
1546
|
+
host_log_path=host_log,
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def _dispatch_constant_command(command: str, *, title: str, **runtime_kwargs: Any) -> None:
|
|
1551
|
+
"""Run one constant host command through a fresh runtime and render it.
|
|
1552
|
+
|
|
1553
|
+
The command is one of the host's fixed admin commands (``/settings``,
|
|
1554
|
+
``/tokens``, ``/debug N``) — no user-controlled grammar, so dispatching
|
|
1555
|
+
via ``handle_message`` costs nothing in parsing fidelity while keeping
|
|
1556
|
+
the runtime's own bookkeeping (thread entry, turn log) intact.
|
|
1557
|
+
"""
|
|
1558
|
+
runtime = _initialize_runtime_or_exit(**runtime_kwargs)
|
|
1559
|
+
if runtime is None:
|
|
1560
|
+
return
|
|
1561
|
+
_restart, text = asyncio.run(_run_host_command_turn(runtime, command))
|
|
1562
|
+
_render_markdown_response(text, title=title)
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
McpIntrospection = tuple[dict[str, Any], dict[str, list[MCPToolSpec]]]
|
|
1566
|
+
"""One MCP introspection result: ``(health, tools_by_server)``.
|
|
1567
|
+
|
|
1568
|
+
``health`` is a ``dict[str, McpServerHealth]`` keyed by ``server_id``;
|
|
1569
|
+
``tools_by_server`` is a ``dict[str, list[MCPToolSpec]]`` grouping the
|
|
1570
|
+
discovered tool specs by their owning server. Both are collected by
|
|
1571
|
+
warming the MCP pool (see :func:`_collect_mcp_tools`). The CLI render
|
|
1572
|
+
layer reads health for the summary table and the specs for the per-server
|
|
1573
|
+
tool detail.
|
|
1574
|
+
"""
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
def _mcp_configs(runtime: Any, *, warm: bool) -> list[Any]:
|
|
1578
|
+
"""Return the runtime's warm or cold MCP configs.
|
|
1579
|
+
|
|
1580
|
+
Reads ``runtime.resources.mcp_warm_configs`` / ``mcp_cold_configs`` on a
|
|
1581
|
+
real :class:`HostRuntime`. Falls back to flat ``mcp_warm_configs`` /
|
|
1582
|
+
``mcp_cold_configs`` attributes so test stubs need not model the
|
|
1583
|
+
``resources`` indirection.
|
|
1584
|
+
"""
|
|
1585
|
+
attr = "mcp_warm_configs" if warm else "mcp_cold_configs"
|
|
1586
|
+
resources = getattr(runtime, "resources", None)
|
|
1587
|
+
configs = getattr(resources, attr, None)
|
|
1588
|
+
if configs is None:
|
|
1589
|
+
configs = getattr(runtime, attr, None)
|
|
1590
|
+
return list(configs or [])
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def _mcp_tool_pool(runtime: Any) -> Any:
|
|
1594
|
+
"""Return the runtime's MCP tool pool (``resources`` indirection tolerated)."""
|
|
1595
|
+
resources = getattr(runtime, "resources", None)
|
|
1596
|
+
pool = getattr(resources, "mcp_tool_pool", None)
|
|
1597
|
+
if pool is None:
|
|
1598
|
+
pool = getattr(runtime, "mcp_tool_pool", None)
|
|
1599
|
+
return pool
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _build_mcp_health(
|
|
1603
|
+
pool: Any,
|
|
1604
|
+
is_alias: dict[str, bool],
|
|
1605
|
+
) -> dict[str, McpServerHealth]:
|
|
1606
|
+
"""Build a per-server health dict from the live pool's introspection accessors.
|
|
1607
|
+
|
|
1608
|
+
Mirrors :func:`runtime.introspect.gather_mcp_pool_health` but runs
|
|
1609
|
+
in the CLI's short-lived ``asyncio.run`` event loop (the pool is started
|
|
1610
|
+
and torn down within ``_collect_mcp_tools``). Reads the same accessors
|
|
1611
|
+
(``warm_server_ids``, ``warm_failed_ids``, ``cold_failed_ids``,
|
|
1612
|
+
``permanently_failed_ids``, ``restart_counts``, ``last_errors``,
|
|
1613
|
+
``tools_by_server``) so the CLI summary shows identical state to any
|
|
1614
|
+
other host surface.
|
|
1615
|
+
"""
|
|
1616
|
+
from runtime.introspect import gather_mcp_pool_health
|
|
1617
|
+
|
|
1618
|
+
health = gather_mcp_pool_health(pool)
|
|
1619
|
+
# Annotate alias tools on the tool_names so the render layer can mark
|
|
1620
|
+
# them without a separate lookup. The specs themselves carry the real
|
|
1621
|
+
# server_id; this is purely a display hint.
|
|
1622
|
+
_ = is_alias # reserved for a future render-layer annotation pass
|
|
1623
|
+
return health
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
async def _aclose_runtime_bounded(runtime: HostRuntime, *, timeout_s: float = 30.0) -> None:
|
|
1627
|
+
"""Tear a runtime down without ever hanging the one-shot CLI command.
|
|
1628
|
+
|
|
1629
|
+
``runtime.aclose()`` closes the MCP pool's anyio contexts and terminates
|
|
1630
|
+
proxy subprocesses. When those teardown awaits stall (the 2026-09-21
|
|
1631
|
+
headroom incident: the loop sat idle in select after sequential anyio
|
|
1632
|
+
task-group exits), a plain ``await runtime.aclose()`` never returns and
|
|
1633
|
+
the CLI prints nothing. Bound the graceful path with
|
|
1634
|
+
:func:`asyncio.wait_for`; on timeout, fall back to terminating every
|
|
1635
|
+
tracked MCP/proxy subprocess directly so no orphans survive.
|
|
1636
|
+
"""
|
|
1637
|
+
try:
|
|
1638
|
+
await asyncio.wait_for(runtime.aclose(), timeout=timeout_s)
|
|
1639
|
+
return
|
|
1640
|
+
except TimeoutError:
|
|
1641
|
+
logger.warning(
|
|
1642
|
+
"Runtime teardown exceeded %.0fs; falling back to direct subprocess termination",
|
|
1643
|
+
timeout_s,
|
|
1644
|
+
)
|
|
1645
|
+
except asyncio.CancelledError:
|
|
1646
|
+
raise
|
|
1647
|
+
except Exception: # pylint: disable=broad-except
|
|
1648
|
+
logger.debug("Runtime aclose raised during bounded teardown", exc_info=True)
|
|
1649
|
+
return
|
|
1650
|
+
# Forced cleanup: kill every subprocess the pools still track.
|
|
1651
|
+
resources = runtime.resources
|
|
1652
|
+
pools: list[Any] = []
|
|
1653
|
+
proxy_pool = getattr(resources, "_proxy_pool", None)
|
|
1654
|
+
if proxy_pool is not None:
|
|
1655
|
+
pools.append(proxy_pool)
|
|
1656
|
+
tool_pool = getattr(resources, "_mcp_tool_pool", None)
|
|
1657
|
+
if tool_pool is not None:
|
|
1658
|
+
pools.append(tool_pool)
|
|
1659
|
+
for pool in pools:
|
|
1660
|
+
await _force_close_pool(pool)
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
async def _force_close_pool(pool: Any) -> None:
|
|
1664
|
+
for process in list(getattr(pool, "_processes", {}).values()):
|
|
1665
|
+
try:
|
|
1666
|
+
if process.returncode is None:
|
|
1667
|
+
process.kill()
|
|
1668
|
+
except ProcessLookupError:
|
|
1669
|
+
pass
|
|
1670
|
+
except Exception: # pylint: disable=broad-except
|
|
1671
|
+
logger.debug("Process kill failed during forced teardown", exc_info=True)
|
|
1672
|
+
for stack in list(getattr(pool, "_child_stacks", {}).values()):
|
|
1673
|
+
try:
|
|
1674
|
+
await stack.aclose()
|
|
1675
|
+
except Exception: # pylint: disable=broad-except
|
|
1676
|
+
logger.debug("Child stack close failed during forced teardown", exc_info=True)
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
async def _collect_mcp_tools(
|
|
1680
|
+
runtime: HostRuntime,
|
|
1681
|
+
*,
|
|
1682
|
+
connect_server_ids: set[str] | None = None,
|
|
1683
|
+
) -> McpIntrospection:
|
|
1684
|
+
"""Warm the MCP pool, return per-server health + discovered tool specs.
|
|
1685
|
+
|
|
1686
|
+
MCP tool names are only discoverable by calling ``tools/list`` on each
|
|
1687
|
+
server, which requires a live session. This starts the pool, reads the
|
|
1688
|
+
discovered tool specs and per-server health, and tears the pool down —
|
|
1689
|
+
all inside one ``asyncio.run`` event loop to honor anyio's same-loop
|
|
1690
|
+
contract (the pool's async context managers must be entered and exited
|
|
1691
|
+
in the same loop).
|
|
1692
|
+
|
|
1693
|
+
By default ONLY warm servers are connected: ``list_tools`` lazily
|
|
1694
|
+
connects cold servers, and sequentially exiting several cold stdio
|
|
1695
|
+
anyio task groups in one task poisons that task — every subsequent
|
|
1696
|
+
await is cancelled by a stale anyio cancel scope (the 2026-09-21
|
|
1697
|
+
headroom incident: ``lisa mcp`` printed nothing and exited 1). Cold
|
|
1698
|
+
servers stay disconnected in the summary (status ``cold``); ``lisa mcp
|
|
1699
|
+
<server_id>`` drill-down passes the single target via
|
|
1700
|
+
``connect_server_ids`` so at most one cold context is ever opened and
|
|
1701
|
+
closed in the CLI's task.
|
|
1702
|
+
|
|
1703
|
+
Returns an empty :data:`McpIntrospection` when the runtime has no MCP
|
|
1704
|
+
servers configured or when pool startup fails, so the ``tools``/``mcp``
|
|
1705
|
+
commands degrade to showing only the internal tool catalog instead of
|
|
1706
|
+
crashing.
|
|
1707
|
+
"""
|
|
1708
|
+
warm_ids = {c.server_id for c in _mcp_configs(runtime, warm=True) if hasattr(c, "server_id")}
|
|
1709
|
+
if not warm_ids and not _mcp_configs(runtime, warm=False):
|
|
1710
|
+
return ({}, {})
|
|
1711
|
+
allowed = warm_ids | (connect_server_ids or set())
|
|
1712
|
+
health: dict[str, Any] = {}
|
|
1713
|
+
tools_by_server: dict[str, list[MCPToolSpec]] = {}
|
|
1714
|
+
try:
|
|
1715
|
+
await runtime.start()
|
|
1716
|
+
pool = _mcp_tool_pool(runtime)
|
|
1717
|
+
if pool is None:
|
|
1718
|
+
return ({}, {})
|
|
1719
|
+
tools = await pool.list_tools(allowed_server_ids=allowed)
|
|
1720
|
+
# Capture is_alias flags BEFORE aclose() clears _synthetic_handlers.
|
|
1721
|
+
is_alias = {spec.name: pool.is_synthetic_tool(spec.name) for spec in tools}
|
|
1722
|
+
for spec in tools:
|
|
1723
|
+
tools_by_server.setdefault(spec.server_id, []).append(spec)
|
|
1724
|
+
health = _build_mcp_health(pool, is_alias)
|
|
1725
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
1726
|
+
logger.debug("MCP tool collection failed: %s", exc)
|
|
1727
|
+
return ({}, {})
|
|
1728
|
+
finally:
|
|
1729
|
+
await _aclose_runtime_bounded(runtime)
|
|
1730
|
+
return (health, tools_by_server)
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
# ---------------------------------------------------------------------------
|
|
1734
|
+
# mcp command
|
|
1735
|
+
# ---------------------------------------------------------------------------
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
@app.command(
|
|
1739
|
+
"mcp",
|
|
1740
|
+
help=(
|
|
1741
|
+
"List MCP servers with status, transport, URL/command, tool count, "
|
|
1742
|
+
"and last error. Pass a server id to drill into that server's tools "
|
|
1743
|
+
"(name, description, required args)."
|
|
1744
|
+
),
|
|
1745
|
+
)
|
|
1746
|
+
def _mcp_command(
|
|
1747
|
+
target: str = typer.Argument(
|
|
1748
|
+
"",
|
|
1749
|
+
help="Optional server id to drill into that server's tools.",
|
|
1750
|
+
),
|
|
1751
|
+
log_level: LogLevelOption = "",
|
|
1752
|
+
log: LogOption = "",
|
|
1753
|
+
no_log: NoLogOption = False,
|
|
1754
|
+
data_dir: DataDirOption = "",
|
|
1755
|
+
profile: ProfileOption = "",
|
|
1756
|
+
thread_dir: ThreadDirOption = "",
|
|
1757
|
+
no_thread: NoThreadOption = False,
|
|
1758
|
+
session_context: SessionContextOption = "",
|
|
1759
|
+
timezone: TimezoneOption = "",
|
|
1760
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1761
|
+
streaming: StreamingOption = "",
|
|
1762
|
+
host_log: HostLogOption = "",
|
|
1763
|
+
) -> None:
|
|
1764
|
+
runtime = _initialize_runtime_or_exit(
|
|
1765
|
+
log_level=log_level,
|
|
1766
|
+
log_path=log,
|
|
1767
|
+
no_log=no_log,
|
|
1768
|
+
data_dir=data_dir,
|
|
1769
|
+
profile=profile,
|
|
1770
|
+
thread_dir=thread_dir,
|
|
1771
|
+
no_thread=no_thread,
|
|
1772
|
+
session_context=session_context,
|
|
1773
|
+
timezone=timezone,
|
|
1774
|
+
state_snapshot_path=state_snapshot,
|
|
1775
|
+
streaming=streaming,
|
|
1776
|
+
host_log_path=host_log,
|
|
1777
|
+
)
|
|
1778
|
+
if runtime is None:
|
|
1779
|
+
return
|
|
1780
|
+
server_id = target.strip()
|
|
1781
|
+
# Warm servers only by default — cold servers stay disconnected so the
|
|
1782
|
+
# one-shot command never sequentially exits multiple cold stdio task
|
|
1783
|
+
# groups in one task (anyio cancel-scope poison, 2026-09-21). Drill-down
|
|
1784
|
+
# adds exactly ONE cold target to the same pass: a single cold context
|
|
1785
|
+
# open/close in the CLI's task is safe (verified), while a second
|
|
1786
|
+
# in-process runtime init is not (stale global tool catalog breaks the
|
|
1787
|
+
# operation-catalog re-validation on the second boot).
|
|
1788
|
+
mcp_health, mcp_tools_by_server = asyncio.run(
|
|
1789
|
+
_collect_mcp_tools(runtime, connect_server_ids={server_id} if server_id else None)
|
|
1790
|
+
)
|
|
1791
|
+
if not mcp_health:
|
|
1792
|
+
console.print("[yellow]No MCP server tools discovered.[/yellow]")
|
|
1793
|
+
return
|
|
1794
|
+
if server_id:
|
|
1795
|
+
specs = mcp_tools_by_server.get(server_id, [])
|
|
1796
|
+
health = mcp_health.get(server_id)
|
|
1797
|
+
if health is None:
|
|
1798
|
+
console.print(f"[yellow]No MCP server named {server_id!r} is configured.[/yellow]")
|
|
1799
|
+
return
|
|
1800
|
+
render_mcp_server_tools(specs, health, console=console)
|
|
1801
|
+
else:
|
|
1802
|
+
render_mcp_servers(mcp_health, console=console)
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
# ---------------------------------------------------------------------------
|
|
1806
|
+
# pack command
|
|
1807
|
+
# ---------------------------------------------------------------------------
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
@app.command("pack", help="Manage runtime pack installs (list/install/uninstall/update).")
|
|
1811
|
+
def _pack_command(
|
|
1812
|
+
action: str = typer.Argument(..., help="Action: list, install, uninstall, update."),
|
|
1813
|
+
target: str = typer.Argument(
|
|
1814
|
+
"",
|
|
1815
|
+
help="Install spec or package name (required for install/uninstall/update <pack>).",
|
|
1816
|
+
),
|
|
1817
|
+
all_packs: AllPacksOption = False,
|
|
1818
|
+
log_level: LogLevelOption = "",
|
|
1819
|
+
log: LogOption = "",
|
|
1820
|
+
no_log: NoLogOption = False,
|
|
1821
|
+
data_dir: DataDirOption = "",
|
|
1822
|
+
profile: ProfileOption = "",
|
|
1823
|
+
thread_dir: ThreadDirOption = "",
|
|
1824
|
+
no_thread: NoThreadOption = False,
|
|
1825
|
+
session_context: SessionContextOption = "",
|
|
1826
|
+
timezone: TimezoneOption = "",
|
|
1827
|
+
state_snapshot: StateSnapshotOption = "",
|
|
1828
|
+
streaming: StreamingOption = "",
|
|
1829
|
+
host_log: HostLogOption = "",
|
|
1830
|
+
) -> None:
|
|
1831
|
+
runtime = _initialize_runtime_or_exit(
|
|
1832
|
+
log_level=log_level,
|
|
1833
|
+
log_path=log,
|
|
1834
|
+
no_log=no_log,
|
|
1835
|
+
data_dir=data_dir,
|
|
1836
|
+
profile=profile,
|
|
1837
|
+
thread_dir=thread_dir,
|
|
1838
|
+
no_thread=no_thread,
|
|
1839
|
+
session_context=session_context,
|
|
1840
|
+
timezone=timezone,
|
|
1841
|
+
state_snapshot_path=state_snapshot,
|
|
1842
|
+
streaming=streaming,
|
|
1843
|
+
host_log_path=host_log,
|
|
1844
|
+
)
|
|
1845
|
+
if runtime is None:
|
|
1846
|
+
return
|
|
1847
|
+
|
|
1848
|
+
normalized = action.strip().lower()
|
|
1849
|
+
if normalized == "list":
|
|
1850
|
+
render_pack_list_panel(runtime, console=console)
|
|
1851
|
+
return
|
|
1852
|
+
if normalized == "install":
|
|
1853
|
+
_finish_pack_mutation(runtime, asyncio.run(_run_pack_install(runtime, target)))
|
|
1854
|
+
return
|
|
1855
|
+
if normalized == "uninstall":
|
|
1856
|
+
_finish_pack_mutation(runtime, asyncio.run(_run_pack_uninstall(runtime, target)))
|
|
1857
|
+
return
|
|
1858
|
+
if normalized == "update":
|
|
1859
|
+
if all_packs:
|
|
1860
|
+
_finish_pack_mutation(runtime, asyncio.run(_run_pack_update_all(runtime)))
|
|
1861
|
+
return
|
|
1862
|
+
if not target.strip():
|
|
1863
|
+
console.print("[red]Usage: lisa pack update <pack> | lisa pack update --all.[/red]")
|
|
1864
|
+
raise typer.Exit(code=2)
|
|
1865
|
+
_finish_pack_mutation(runtime, asyncio.run(_run_pack_update(runtime, target.strip())))
|
|
1866
|
+
return
|
|
1867
|
+
|
|
1868
|
+
console.print("[red]Unknown action. Use: list, install, uninstall, update.[/red]")
|
|
1869
|
+
raise typer.Exit(code=2)
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def _finish_pack_mutation(runtime: HostRuntime, result: tuple[bool, str]) -> None:
|
|
1873
|
+
"""Print a pack lifecycle result and apply the exit-code contract.
|
|
1874
|
+
|
|
1875
|
+
Failure prints the details in red and exits 1; success prints the
|
|
1876
|
+
details and exits via :func:`_exit_restart_required` when the runtime
|
|
1877
|
+
flagged a restart, else 0.
|
|
1878
|
+
"""
|
|
1879
|
+
ok, details = result
|
|
1880
|
+
if not ok:
|
|
1881
|
+
console.print(f"[red]{details}[/red]")
|
|
1882
|
+
raise typer.Exit(code=1)
|
|
1883
|
+
console.print(details)
|
|
1884
|
+
if runtime.session.restart_required:
|
|
1885
|
+
_exit_restart_required()
|
|
1886
|
+
raise typer.Exit(code=0)
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
async def _run_pack_install(runtime: HostRuntime, spec: str) -> tuple[bool, str]:
|
|
1890
|
+
"""Install a pack by spec (delegates to host pack management)."""
|
|
1891
|
+
from runtime.commands import run_pack_install
|
|
1892
|
+
|
|
1893
|
+
return await run_pack_install(runtime, spec)
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
async def _run_pack_uninstall(runtime: HostRuntime, package_name: str) -> tuple[bool, str]:
|
|
1897
|
+
"""Uninstall a pack by package name."""
|
|
1898
|
+
from runtime.commands import run_pack_uninstall
|
|
1899
|
+
|
|
1900
|
+
return await run_pack_uninstall(runtime, package_name)
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
async def _run_pack_update(runtime: HostRuntime, package_name: str) -> tuple[bool, str]:
|
|
1904
|
+
"""Upgrade one installed pack in place from its recorded install source."""
|
|
1905
|
+
from runtime.commands import run_pack_update
|
|
1906
|
+
|
|
1907
|
+
return await run_pack_update(runtime, package_name)
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
async def _run_pack_update_all(runtime: HostRuntime) -> tuple[bool, str]:
|
|
1911
|
+
"""Upgrade every runtime-installed pack with a recorded install source."""
|
|
1912
|
+
from runtime.commands import run_pack_update_all
|
|
1913
|
+
|
|
1914
|
+
return await run_pack_update_all(runtime)
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
# ---------------------------------------------------------------------------
|
|
1918
|
+
# prompt command
|
|
1919
|
+
# ---------------------------------------------------------------------------
|
|
1920
|
+
|
|
1921
|
+
# Prepended to the turn message in --raw mode: the response is consumed
|
|
1922
|
+
# verbatim as a machine artifact, so the model must emit only the result.
|
|
1923
|
+
# The yield prohibition is explicit: a prior turn ended in the `yield`
|
|
1924
|
+
# tool (asking the operator for missing input), which wrote the question
|
|
1925
|
+
# prose into redirected output as if it were the artifact.
|
|
1926
|
+
_RAW_CONTRACT = (
|
|
1927
|
+
"OUTPUT CONTRACT (raw mode): Your entire response text is consumed verbatim "
|
|
1928
|
+
"as a machine artifact (redirected to a file or piped to another tool). "
|
|
1929
|
+
"Emit ONLY the requested result — no preamble, no closing commentary, no "
|
|
1930
|
+
"restating the task, no follow-up questions, and do not wrap the whole "
|
|
1931
|
+
"output in a markdown code fence. "
|
|
1932
|
+
"Do NOT call the yield/ask tool and do not end with a question: if required "
|
|
1933
|
+
"input is missing, make the best-supported inference from the provided "
|
|
1934
|
+
"material and state the assumption in a trailing comment (e.g. '# NOTE: ...'), "
|
|
1935
|
+
"then still emit the full result. A question or an empty response is a "
|
|
1936
|
+
"failed turn."
|
|
1937
|
+
)
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
class _PrearmedDecision(NamedTuple):
|
|
1941
|
+
"""One pre-armed approval decision parsed from prompt CLI flags."""
|
|
1942
|
+
|
|
1943
|
+
request_id: str
|
|
1944
|
+
approve: bool
|
|
1945
|
+
remember: str
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
def _prompt_prearmed_decision(approve: str, deny: str, remember: str) -> _PrearmedDecision | None:
|
|
1949
|
+
"""Parse the pre-armed decision flags for a prompt turn, or None.
|
|
1950
|
+
|
|
1951
|
+
``--approve``/``--deny`` accept either a request id or, bare, the
|
|
1952
|
+
sentinel "pending" (an empty ``request_id`` here, resolved to the
|
|
1953
|
+
single pending approval via the host's recorded-owner identity).
|
|
1954
|
+
``--remember <prefix>`` only applies to approve; deny + remember is a
|
|
1955
|
+
usage error.
|
|
1956
|
+
"""
|
|
1957
|
+
approve = approve.strip()
|
|
1958
|
+
deny = deny.strip()
|
|
1959
|
+
remember = remember.strip()
|
|
1960
|
+
if approve and deny:
|
|
1961
|
+
console.print("[red]--approve and --deny are mutually exclusive.[/red]")
|
|
1962
|
+
raise typer.Exit(code=2)
|
|
1963
|
+
if remember and not approve:
|
|
1964
|
+
console.print("[red]--remember requires --approve.[/red]")
|
|
1965
|
+
raise typer.Exit(code=2)
|
|
1966
|
+
if not approve and not deny:
|
|
1967
|
+
return None
|
|
1968
|
+
if approve:
|
|
1969
|
+
target = "" if approve.lower() == _PENDING_APPROVAL_TARGET else approve
|
|
1970
|
+
return _PrearmedDecision(request_id=target, approve=True, remember=remember)
|
|
1971
|
+
target = deny if deny.lower() != _PENDING_APPROVAL_TARGET else ""
|
|
1972
|
+
return _PrearmedDecision(request_id=target, approve=False, remember="")
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
async def _run_host_command_turn(runtime: HostRuntime, command: str) -> tuple[bool, str]:
|
|
1976
|
+
"""Run one host-command message (no model turn), then close the pool.
|
|
1977
|
+
|
|
1978
|
+
Mirrors ``_run_prompt_with_teardown`` without transport rendering: the
|
|
1979
|
+
command path returns text (or a structured result) that this helper
|
|
1980
|
+
prints through the shared console. The MCP pool starts in this event
|
|
1981
|
+
loop and closes in the same one (anyio same-loop contract).
|
|
1982
|
+
"""
|
|
1983
|
+
from runtime import NullTransport
|
|
1984
|
+
from runtime.handle_message import handle_message
|
|
1985
|
+
|
|
1986
|
+
try:
|
|
1987
|
+
await runtime.start()
|
|
1988
|
+
result = await handle_message(runtime, command, NullTransport())
|
|
1989
|
+
finally:
|
|
1990
|
+
await runtime.aclose()
|
|
1991
|
+
text = getattr(result, "text", "") or str(result)
|
|
1992
|
+
return runtime.session.restart_required, text
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
async def _resolve_pending_request_id(runtime: HostRuntime) -> str:
|
|
1996
|
+
"""Return the single pending approval's id, or '' when ambiguous/none.
|
|
1997
|
+
|
|
1998
|
+
The approval store is process-durable and identity-scoped, so a fresh
|
|
1999
|
+
prompt process sees the request a prior prompt run left pending. With
|
|
2000
|
+
exactly one pending record the bare decision targets it; zero or
|
|
2001
|
+
multiple pendings return '' so the typed core replies with its own
|
|
2002
|
+
diagnostic (usage/expired/ambiguous).
|
|
2003
|
+
"""
|
|
2004
|
+
service = getattr(runtime.services, "approval_service", None)
|
|
2005
|
+
if service is None or not hasattr(service, "list_pending"):
|
|
2006
|
+
return ""
|
|
2007
|
+
from runtime.approval import approval_identity
|
|
2008
|
+
|
|
2009
|
+
try:
|
|
2010
|
+
pending = await service.list_pending(approval_identity(runtime))
|
|
2011
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
2012
|
+
logger.debug("prompt: pending-approval resolution failed: %s", exc)
|
|
2013
|
+
return ""
|
|
2014
|
+
if len(pending) == 1:
|
|
2015
|
+
request_id = pending[0].invocation.request_id
|
|
2016
|
+
logger.info("prompt: resolved bare decision to pending %s", request_id[:8])
|
|
2017
|
+
return request_id
|
|
2018
|
+
return ""
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
async def _decide_approval_turn(
|
|
2022
|
+
runtime: HostRuntime,
|
|
2023
|
+
*,
|
|
2024
|
+
request_id: str,
|
|
2025
|
+
approve: bool,
|
|
2026
|
+
remember: str,
|
|
2027
|
+
) -> tuple[bool, str]:
|
|
2028
|
+
"""Decide one approval through the typed core, then close the pool.
|
|
2029
|
+
|
|
2030
|
+
Mirrors ``_run_host_command_turn``: no transport rendering — the
|
|
2031
|
+
decision path returns text (or a structured continuation result) that
|
|
2032
|
+
this helper prints through the shared console. A bare decision (empty
|
|
2033
|
+
``request_id``) resolves to the single pending approval first — the
|
|
2034
|
+
"decide the thing that just stopped me" case.
|
|
2035
|
+
"""
|
|
2036
|
+
if not request_id:
|
|
2037
|
+
request_id = await _resolve_pending_request_id(runtime)
|
|
2038
|
+
from runtime import NullTransport
|
|
2039
|
+
from runtime.handle_message import decide_pending_approval
|
|
2040
|
+
|
|
2041
|
+
try:
|
|
2042
|
+
await runtime.start()
|
|
2043
|
+
result = await decide_pending_approval(
|
|
2044
|
+
runtime,
|
|
2045
|
+
request_id,
|
|
2046
|
+
approve=approve,
|
|
2047
|
+
# The CLI's --remember is a value flag (explicit prefix or
|
|
2048
|
+
# absent): the default-prefix behavior belongs to the slash
|
|
2049
|
+
# grammar, not here. No --remember means no rule write.
|
|
2050
|
+
remember=remember or None,
|
|
2051
|
+
remember_default=False,
|
|
2052
|
+
transport=NullTransport(),
|
|
2053
|
+
)
|
|
2054
|
+
finally:
|
|
2055
|
+
await runtime.aclose()
|
|
2056
|
+
text = getattr(result, "text", "") or str(result)
|
|
2057
|
+
return runtime.session.restart_required, text
|
|
2058
|
+
|
|
2059
|
+
|
|
2060
|
+
def _run_decision(
|
|
2061
|
+
request_id: str,
|
|
2062
|
+
*,
|
|
2063
|
+
approve: bool,
|
|
2064
|
+
remember: str,
|
|
2065
|
+
**runtime_kwargs: Any,
|
|
2066
|
+
) -> None:
|
|
2067
|
+
"""Decide one pending approval through the typed core in a fresh runtime.
|
|
2068
|
+
|
|
2069
|
+
The CLI's runtime options are forwarded verbatim; the decision is a
|
|
2070
|
+
value (id + direction + optional remember prefix), never a command
|
|
2071
|
+
string, so no quoting round trip exists between the CLI and the host.
|
|
2072
|
+
Prints the reply and applies the restart-required exit contract.
|
|
2073
|
+
"""
|
|
2074
|
+
runtime = _initialize_runtime_or_exit(**runtime_kwargs)
|
|
2075
|
+
if runtime is None:
|
|
2076
|
+
return
|
|
2077
|
+
restart_required, text = asyncio.run(
|
|
2078
|
+
_decide_approval_turn(runtime, request_id=request_id, approve=approve, remember=remember)
|
|
2079
|
+
)
|
|
2080
|
+
if text:
|
|
2081
|
+
console.print(text)
|
|
2082
|
+
if restart_required:
|
|
2083
|
+
_exit_restart_required()
|
|
2084
|
+
raise typer.Exit(code=0)
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
def _run_host_command(command: str, **runtime_kwargs: Any) -> None:
|
|
2088
|
+
"""Run a host-owned admin command through a fresh runtime.
|
|
2089
|
+
|
|
2090
|
+
The CLI's runtime options are forwarded verbatim; the command string is
|
|
2091
|
+
normalized to its slash form and dispatched via ``handle_message``
|
|
2092
|
+
exactly as a persistent host would send it. This keeps pack commands and
|
|
2093
|
+
other host-admin replies host-owned while bypassing the model-turn path.
|
|
2094
|
+
Prints the reply and applies the restart-required exit contract when
|
|
2095
|
+
required.
|
|
2096
|
+
"""
|
|
2097
|
+
if not command.startswith("/"):
|
|
2098
|
+
command = "/" + command
|
|
2099
|
+
runtime = _initialize_runtime_or_exit(**runtime_kwargs)
|
|
2100
|
+
if runtime is None:
|
|
2101
|
+
return
|
|
2102
|
+
restart_required, text = asyncio.run(_run_host_command_turn(runtime, command))
|
|
2103
|
+
if text:
|
|
2104
|
+
console.print(text)
|
|
2105
|
+
if restart_required:
|
|
2106
|
+
_exit_restart_required()
|
|
2107
|
+
raise typer.Exit(code=0)
|
|
2108
|
+
|
|
2109
|
+
|
|
2110
|
+
def _run_policy_action(action: Callable[[HostRuntime], str], **runtime_kwargs: Any) -> None:
|
|
2111
|
+
"""Run one typed policy action against a fresh runtime.
|
|
2112
|
+
|
|
2113
|
+
``action`` receives the runtime and returns the reply text — the typed
|
|
2114
|
+
cores (:func:`set_policy_mode`, :func:`add_policy_rule`, ...) take
|
|
2115
|
+
values, never command strings, so no quoting round trip exists between
|
|
2116
|
+
the CLI flags and the policy write. The host owns the write.
|
|
2117
|
+
"""
|
|
2118
|
+
runtime = _initialize_runtime_or_exit(**runtime_kwargs)
|
|
2119
|
+
if runtime is None:
|
|
2120
|
+
return
|
|
2121
|
+
|
|
2122
|
+
console.print(action(runtime))
|
|
2123
|
+
raise typer.Exit(code=0)
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
@policy_app.callback()
|
|
2127
|
+
def _policy_group( # Typer exposes shared runtime options as CLI flags.
|
|
2128
|
+
ctx: typer.Context,
|
|
2129
|
+
log_level: LogLevelOption = "",
|
|
2130
|
+
log: LogOption = "",
|
|
2131
|
+
no_log: NoLogOption = False,
|
|
2132
|
+
data_dir: DataDirOption = "",
|
|
2133
|
+
profile: ProfileOption = "",
|
|
2134
|
+
overlays_dir: OverlaysDirOption = "",
|
|
2135
|
+
thread_dir: ThreadDirOption = "",
|
|
2136
|
+
no_thread: NoThreadOption = False,
|
|
2137
|
+
session_context: SessionContextOption = "",
|
|
2138
|
+
timezone: TimezoneOption = "",
|
|
2139
|
+
state_snapshot: StateSnapshotOption = "",
|
|
2140
|
+
streaming: StreamingOption = "",
|
|
2141
|
+
host_log: HostLogOption = "",
|
|
2142
|
+
) -> None:
|
|
2143
|
+
"""Set up shared host options for policy commands; bare `policy` shows status."""
|
|
2144
|
+
ctx.obj = {
|
|
2145
|
+
"log_level": log_level,
|
|
2146
|
+
"log_path": log,
|
|
2147
|
+
"no_log": no_log,
|
|
2148
|
+
"data_dir": data_dir,
|
|
2149
|
+
"profile": profile,
|
|
2150
|
+
"overlays_dir": overlays_dir,
|
|
2151
|
+
"thread_dir": thread_dir,
|
|
2152
|
+
"no_thread": no_thread,
|
|
2153
|
+
"session_context": session_context,
|
|
2154
|
+
"timezone": timezone,
|
|
2155
|
+
"state_snapshot_path": state_snapshot,
|
|
2156
|
+
"streaming": streaming,
|
|
2157
|
+
"host_log_path": host_log,
|
|
2158
|
+
}
|
|
2159
|
+
if ctx.invoked_subcommand is None:
|
|
2160
|
+
|
|
2161
|
+
def _show_document(runtime: HostRuntime) -> str:
|
|
2162
|
+
from runtime.commands import render_policy_document
|
|
2163
|
+
|
|
2164
|
+
return render_policy_document(runtime)
|
|
2165
|
+
|
|
2166
|
+
_run_policy_action(_show_document, **ctx.obj)
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
@policy_app.command("mode", help="Set the fallback approval mode: ask, trust, or plan.")
|
|
2170
|
+
def _policy_mode(ctx: typer.Context, mode: Literal["ask", "trust", "plan"]) -> None:
|
|
2171
|
+
def _set_mode(runtime: HostRuntime) -> str:
|
|
2172
|
+
from runtime.commands import set_policy_mode
|
|
2173
|
+
from runtime.policy import PolicyMode
|
|
2174
|
+
|
|
2175
|
+
return set_policy_mode(runtime, PolicyMode(mode))
|
|
2176
|
+
|
|
2177
|
+
_run_policy_action(_set_mode, **ctx.obj)
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
@policy_app.command("bare", help="Toggle approval for unsandboxed execute actions.")
|
|
2181
|
+
def _policy_bare(ctx: typer.Context, setting: Literal["on", "off"]) -> None:
|
|
2182
|
+
def _set_bare(runtime: HostRuntime) -> str:
|
|
2183
|
+
from runtime.commands import set_policy_bare
|
|
2184
|
+
|
|
2185
|
+
return set_policy_bare(runtime, setting == "on")
|
|
2186
|
+
|
|
2187
|
+
_run_policy_action(_set_bare, **ctx.obj)
|
|
2188
|
+
|
|
2189
|
+
|
|
2190
|
+
@policy_rule_app.command("list", help="List allow, ask, and deny rules with remove addresses.")
|
|
2191
|
+
def _policy_rule_list(ctx: typer.Context) -> None:
|
|
2192
|
+
def _list_rules(runtime: HostRuntime) -> str:
|
|
2193
|
+
from runtime.commands import render_policy_rules
|
|
2194
|
+
|
|
2195
|
+
return render_policy_rules(runtime)
|
|
2196
|
+
|
|
2197
|
+
_run_policy_action(_list_rules, **ctx.obj)
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
@policy_rule_app.command("remove", help="Remove a rule by its list address, e.g. allow:1.")
|
|
2201
|
+
def _policy_rule_remove(ctx: typer.Context, address: str) -> None:
|
|
2202
|
+
def _remove_rule(runtime: HostRuntime) -> str:
|
|
2203
|
+
from runtime.commands import remove_policy_rule
|
|
2204
|
+
|
|
2205
|
+
return remove_policy_rule(runtime, address)
|
|
2206
|
+
|
|
2207
|
+
_run_policy_action(_remove_rule, **ctx.obj)
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
@policy_rule_app.command("add", help="Add an allow, ask, or deny rule.")
|
|
2211
|
+
def _policy_rule_add( # Typer requires each rule matcher as a CLI option.
|
|
2212
|
+
ctx: typer.Context,
|
|
2213
|
+
rule_list: Literal["allow", "ask", "deny"],
|
|
2214
|
+
tool: str = typer.Option("", "--tool", help="Exact capability tool ID."),
|
|
2215
|
+
effect: str = typer.Option("", "--effect", help="Exact effect kind."),
|
|
2216
|
+
pack: str = typer.Option("", "--pack", help="Exact owning pack ID."),
|
|
2217
|
+
prefix: str = typer.Option("", "--prefix", help="Command prefix matcher."),
|
|
2218
|
+
when: str = typer.Option("", "--when", help="CEL expression; quote the whole expression."),
|
|
2219
|
+
) -> None:
|
|
2220
|
+
"""Parse policy matchers with Typer and pass their values to the host policy writer."""
|
|
2221
|
+
|
|
2222
|
+
def _add_rule(runtime: HostRuntime) -> str:
|
|
2223
|
+
from runtime.commands import add_policy_rule
|
|
2224
|
+
|
|
2225
|
+
return add_policy_rule(
|
|
2226
|
+
runtime,
|
|
2227
|
+
rule_list,
|
|
2228
|
+
tool=tool,
|
|
2229
|
+
effect=effect,
|
|
2230
|
+
pack=pack,
|
|
2231
|
+
command_prefix=prefix,
|
|
2232
|
+
when=when,
|
|
2233
|
+
)
|
|
2234
|
+
|
|
2235
|
+
_run_policy_action(_add_rule, **ctx.obj)
|
|
2236
|
+
|
|
2237
|
+
|
|
2238
|
+
@app.command("approve", help="Approve one pending action without starting a model turn.")
|
|
2239
|
+
def _approve_command( # Match shared runtime options used by one-shot commands.
|
|
2240
|
+
request_id: str = typer.Argument(
|
|
2241
|
+
_PENDING_APPROVAL_TARGET,
|
|
2242
|
+
help="Approval request ID, or omit it to target the single pending request.",
|
|
2243
|
+
),
|
|
2244
|
+
remember: str = typer.Option(
|
|
2245
|
+
"",
|
|
2246
|
+
"--remember",
|
|
2247
|
+
help="Persist an allow rule for the action's command prefix.",
|
|
2248
|
+
),
|
|
2249
|
+
log_level: LogLevelOption = "",
|
|
2250
|
+
log: LogOption = "",
|
|
2251
|
+
no_log: NoLogOption = False,
|
|
2252
|
+
data_dir: DataDirOption = "",
|
|
2253
|
+
profile: ProfileOption = "",
|
|
2254
|
+
overlays_dir: OverlaysDirOption = "",
|
|
2255
|
+
thread_dir: ThreadDirOption = "",
|
|
2256
|
+
no_thread: NoThreadOption = False,
|
|
2257
|
+
session_context: SessionContextOption = "",
|
|
2258
|
+
timezone: TimezoneOption = "",
|
|
2259
|
+
state_snapshot: StateSnapshotOption = "",
|
|
2260
|
+
streaming: StreamingOption = "",
|
|
2261
|
+
host_log: HostLogOption = "",
|
|
2262
|
+
) -> None:
|
|
2263
|
+
target = "" if request_id.lower() == _PENDING_APPROVAL_TARGET else request_id
|
|
2264
|
+
_run_decision(
|
|
2265
|
+
target,
|
|
2266
|
+
approve=True,
|
|
2267
|
+
remember=remember,
|
|
2268
|
+
log_level=log_level,
|
|
2269
|
+
log_path=log,
|
|
2270
|
+
no_log=no_log,
|
|
2271
|
+
data_dir=data_dir,
|
|
2272
|
+
profile=profile,
|
|
2273
|
+
overlays_dir=overlays_dir,
|
|
2274
|
+
thread_dir=thread_dir,
|
|
2275
|
+
no_thread=no_thread,
|
|
2276
|
+
session_context=session_context,
|
|
2277
|
+
timezone=timezone,
|
|
2278
|
+
state_snapshot_path=state_snapshot,
|
|
2279
|
+
streaming=streaming,
|
|
2280
|
+
host_log_path=host_log,
|
|
2281
|
+
)
|
|
2282
|
+
|
|
2283
|
+
|
|
2284
|
+
@app.command("deny", help="Deny one pending action without starting a model turn.")
|
|
2285
|
+
def _deny_command( # Match shared runtime options used by one-shot commands.
|
|
2286
|
+
request_id: str = typer.Argument(
|
|
2287
|
+
_PENDING_APPROVAL_TARGET,
|
|
2288
|
+
help="Approval request ID, or omit it to target the single pending request.",
|
|
2289
|
+
),
|
|
2290
|
+
log_level: LogLevelOption = "",
|
|
2291
|
+
log: LogOption = "",
|
|
2292
|
+
no_log: NoLogOption = False,
|
|
2293
|
+
data_dir: DataDirOption = "",
|
|
2294
|
+
profile: ProfileOption = "",
|
|
2295
|
+
overlays_dir: OverlaysDirOption = "",
|
|
2296
|
+
thread_dir: ThreadDirOption = "",
|
|
2297
|
+
no_thread: NoThreadOption = False,
|
|
2298
|
+
session_context: SessionContextOption = "",
|
|
2299
|
+
timezone: TimezoneOption = "",
|
|
2300
|
+
state_snapshot: StateSnapshotOption = "",
|
|
2301
|
+
streaming: StreamingOption = "",
|
|
2302
|
+
host_log: HostLogOption = "",
|
|
2303
|
+
) -> None:
|
|
2304
|
+
target = "" if request_id.lower() == _PENDING_APPROVAL_TARGET else request_id
|
|
2305
|
+
_run_decision(
|
|
2306
|
+
target,
|
|
2307
|
+
approve=False,
|
|
2308
|
+
remember="",
|
|
2309
|
+
log_level=log_level,
|
|
2310
|
+
log_path=log,
|
|
2311
|
+
no_log=no_log,
|
|
2312
|
+
data_dir=data_dir,
|
|
2313
|
+
profile=profile,
|
|
2314
|
+
overlays_dir=overlays_dir,
|
|
2315
|
+
thread_dir=thread_dir,
|
|
2316
|
+
no_thread=no_thread,
|
|
2317
|
+
session_context=session_context,
|
|
2318
|
+
timezone=timezone,
|
|
2319
|
+
state_snapshot_path=state_snapshot,
|
|
2320
|
+
streaming=streaming,
|
|
2321
|
+
host_log_path=host_log,
|
|
2322
|
+
)
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
@app.command(
|
|
2326
|
+
"run",
|
|
2327
|
+
hidden=True,
|
|
2328
|
+
help=(
|
|
2329
|
+
"[DEPRECATED] Run one slash command (host admin or pack-defined). "
|
|
2330
|
+
"Every host command now has a real subcommand (pack-registered ones "
|
|
2331
|
+
"like `stalls`/`retry` are generated from the pack registry); this "
|
|
2332
|
+
"escape hatch remains one release for compatibility. The leading '/' "
|
|
2333
|
+
"is optional. Quote multi-word arguments as one string; with multiple "
|
|
2334
|
+
"tokens they are re-quoted verbatim so the runtime sees the exact "
|
|
2335
|
+
"slash-command string."
|
|
2336
|
+
),
|
|
2337
|
+
)
|
|
2338
|
+
def _run_command_command( # Match shared runtime options used by one-shot commands.
|
|
2339
|
+
command: Annotated[
|
|
2340
|
+
list[str] | None,
|
|
2341
|
+
typer.Argument(help="Slash command and its arguments."),
|
|
2342
|
+
] = None,
|
|
2343
|
+
log_level: LogLevelOption = "",
|
|
2344
|
+
log: LogOption = "",
|
|
2345
|
+
no_log: NoLogOption = False,
|
|
2346
|
+
data_dir: DataDirOption = "",
|
|
2347
|
+
profile: ProfileOption = "",
|
|
2348
|
+
overlays_dir: OverlaysDirOption = "",
|
|
2349
|
+
thread_dir: ThreadDirOption = "",
|
|
2350
|
+
no_thread: NoThreadOption = False,
|
|
2351
|
+
session_context: SessionContextOption = "",
|
|
2352
|
+
timezone: TimezoneOption = "",
|
|
2353
|
+
state_snapshot: StateSnapshotOption = "",
|
|
2354
|
+
streaming: StreamingOption = "",
|
|
2355
|
+
host_log: HostLogOption = "",
|
|
2356
|
+
) -> None:
|
|
2357
|
+
"""Dispatch one slash command to the runtime exactly as a persistent
|
|
2358
|
+
host would, via ``handle_message`` and ``_run_host_command_turn``.
|
|
2359
|
+
|
|
2360
|
+
DEPRECATED: kept one release for compatibility. Every host admin verb
|
|
2361
|
+
now has a tailored Typer command; pack-registered commands (``stalls``,
|
|
2362
|
+
``retry``, ...) resolve as dynamically generated subcommands. This
|
|
2363
|
+
generic string escape hatch hides from help and will be removed once
|
|
2364
|
+
scripts have migrated.
|
|
2365
|
+
"""
|
|
2366
|
+
console.print(
|
|
2367
|
+
"[yellow]`lisa run` is deprecated: host commands are real subcommands "
|
|
2368
|
+
"now (`lisa settings`, `lisa stalls`, ...).[/yellow]"
|
|
2369
|
+
)
|
|
2370
|
+
if not command:
|
|
2371
|
+
console.print("[red]No slash command given. Example: lisa run '/policy mode trust'[/red]")
|
|
2372
|
+
raise typer.Exit(code=2)
|
|
2373
|
+
parts = list(command)
|
|
2374
|
+
command_str = parts[0] if len(parts) == 1 else " ".join(shlex.quote(part) for part in parts)
|
|
2375
|
+
if not command_str.startswith("/"):
|
|
2376
|
+
command_str = "/" + command_str
|
|
2377
|
+
_run_host_command(
|
|
2378
|
+
command_str,
|
|
2379
|
+
log_level=log_level,
|
|
2380
|
+
log_path=log,
|
|
2381
|
+
no_log=no_log,
|
|
2382
|
+
data_dir=data_dir,
|
|
2383
|
+
profile=profile,
|
|
2384
|
+
overlays_dir=overlays_dir,
|
|
2385
|
+
thread_dir=thread_dir,
|
|
2386
|
+
no_thread=no_thread,
|
|
2387
|
+
session_context=session_context,
|
|
2388
|
+
timezone=timezone,
|
|
2389
|
+
state_snapshot_path=state_snapshot,
|
|
2390
|
+
streaming=streaming,
|
|
2391
|
+
host_log_path=host_log,
|
|
2392
|
+
)
|
|
2393
|
+
|
|
2394
|
+
|
|
2395
|
+
def _compose_prompt_message(instruction: str, payload: str) -> str:
|
|
2396
|
+
"""Compose the prompt-mode turn message from the CLI instruction and stdin.
|
|
2397
|
+
|
|
2398
|
+
When both are present the payload is wrapped in a ``<piped-input>`` block
|
|
2399
|
+
ahead of the instruction so the model can tell data from directive;
|
|
2400
|
+
otherwise the non-empty one is the message.
|
|
2401
|
+
"""
|
|
2402
|
+
instruction = instruction.strip()
|
|
2403
|
+
payload = payload.strip()
|
|
2404
|
+
if payload and instruction:
|
|
2405
|
+
return f"<piped-input>\n{payload}\n</piped-input>\n\n{instruction}"
|
|
2406
|
+
return payload or instruction
|
|
2407
|
+
|
|
2408
|
+
|
|
2409
|
+
@app.command("prompt", help="Run a single turn and print the response.")
|
|
2410
|
+
def _prompt_command(
|
|
2411
|
+
message: str = typer.Argument(
|
|
2412
|
+
"",
|
|
2413
|
+
help=(
|
|
2414
|
+
"Message to route through the host. When stdin is piped (not a tty), "
|
|
2415
|
+
"its contents are attached as the turn payload, e.g. "
|
|
2416
|
+
"`cat f.conf | lisa prompt -r 'fix this config'`. "
|
|
2417
|
+
"Approval decisions that skip the turn: `lisa approve <id> "
|
|
2418
|
+
"[--remember <prefix>]` and `lisa deny <id>`."
|
|
2419
|
+
),
|
|
2420
|
+
),
|
|
2421
|
+
stdin: StdinOption = True,
|
|
2422
|
+
raw: RawOption = False,
|
|
2423
|
+
log_level: LogLevelOption = "",
|
|
2424
|
+
log: LogOption = "",
|
|
2425
|
+
no_log: NoLogOption = False,
|
|
2426
|
+
data_dir: DataDirOption = "",
|
|
2427
|
+
profile: ProfileOption = "",
|
|
2428
|
+
overlays_dir: OverlaysDirOption = "",
|
|
2429
|
+
thread_dir: ThreadDirOption = "",
|
|
2430
|
+
no_thread: NoThreadOption = False,
|
|
2431
|
+
session_context: SessionContextOption = "",
|
|
2432
|
+
timezone: TimezoneOption = "",
|
|
2433
|
+
state_snapshot: StateSnapshotOption = "",
|
|
2434
|
+
streaming: StreamingOption = "",
|
|
2435
|
+
host_log: HostLogOption = "",
|
|
2436
|
+
quiet: QuietOption = False,
|
|
2437
|
+
tool_detail: ToolDetailOption = 0,
|
|
2438
|
+
approve: PromptApproveOption = "",
|
|
2439
|
+
deny: PromptDenyOption = "",
|
|
2440
|
+
remember: PromptRememberOption = "",
|
|
2441
|
+
) -> None:
|
|
2442
|
+
# Group-level hoist: `lisa -r "fix it"` parses -r at group level (see
|
|
2443
|
+
# _cli_callback) and lazy-routing lands here; OR the hoisted values in.
|
|
2444
|
+
# Typer commands cannot reliably read click's thread-local context
|
|
2445
|
+
# (get_current_context returns None inside a Typer command body), so
|
|
2446
|
+
# the group callback records the hoisted flags on this module-level
|
|
2447
|
+
# slot — one CLI process dispatches exactly one group callback before
|
|
2448
|
+
# the command body runs.
|
|
2449
|
+
group_obj = _GROUP_HOIST
|
|
2450
|
+
raw = raw or bool(group_obj.get("raw"))
|
|
2451
|
+
quiet = quiet or bool(group_obj.get("quiet"))
|
|
2452
|
+
stdin = stdin and not bool(group_obj.get("no_stdin"))
|
|
2453
|
+
quiet = quiet or bool(group_obj.get("quiet"))
|
|
2454
|
+
stdin = stdin and not bool(group_obj.get("no_stdin"))
|
|
2455
|
+
# Pre-armed decisions are handled below; standalone approve/deny
|
|
2456
|
+
# commands are registered with Typer and never enter this function.
|
|
2457
|
+
payload = ""
|
|
2458
|
+
if stdin and not sys.stdin.isatty():
|
|
2459
|
+
try:
|
|
2460
|
+
payload = sys.stdin.read()
|
|
2461
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
2462
|
+
console.print(f"[red]Failed to read piped stdin: {exc}[/red]")
|
|
2463
|
+
raise typer.Exit(code=2) from exc
|
|
2464
|
+
# A pre-armed decision (--approve/--deny) carries no message and no
|
|
2465
|
+
# piped payload; it is consumed at the end of the turn.
|
|
2466
|
+
decision = _prompt_prearmed_decision(approve, deny, remember)
|
|
2467
|
+
if not message.strip() and not payload.strip() and decision is None:
|
|
2468
|
+
console.print(
|
|
2469
|
+
"[red]No message and no piped input. Pass a message and/or pipe data on stdin.[/red]"
|
|
2470
|
+
)
|
|
2471
|
+
raise typer.Exit(code=2)
|
|
2472
|
+
full_message = _compose_prompt_message(message, payload)
|
|
2473
|
+
if raw:
|
|
2474
|
+
full_message = f"{_RAW_CONTRACT}\n\n{full_message}"
|
|
2475
|
+
runtime = _initialize_runtime_or_exit(
|
|
2476
|
+
log_level=log_level,
|
|
2477
|
+
log_path=log,
|
|
2478
|
+
no_log=no_log,
|
|
2479
|
+
data_dir=data_dir,
|
|
2480
|
+
profile=profile,
|
|
2481
|
+
overlays_dir=overlays_dir,
|
|
2482
|
+
thread_dir=thread_dir,
|
|
2483
|
+
no_thread=no_thread,
|
|
2484
|
+
session_context=session_context,
|
|
2485
|
+
timezone=timezone,
|
|
2486
|
+
state_snapshot_path=state_snapshot,
|
|
2487
|
+
streaming=streaming,
|
|
2488
|
+
host_log_path=host_log,
|
|
2489
|
+
in_process_mcp=True,
|
|
2490
|
+
)
|
|
2491
|
+
if runtime is None:
|
|
2492
|
+
return
|
|
2493
|
+
try:
|
|
2494
|
+
restart_required, transport = asyncio.run(
|
|
2495
|
+
_run_prompt_with_teardown(
|
|
2496
|
+
runtime,
|
|
2497
|
+
full_message,
|
|
2498
|
+
quiet=quiet,
|
|
2499
|
+
raw=raw,
|
|
2500
|
+
tool_detail=tool_detail,
|
|
2501
|
+
streaming_mode=runtime.config.streaming.mode,
|
|
2502
|
+
decision=decision,
|
|
2503
|
+
)
|
|
2504
|
+
)
|
|
2505
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
2506
|
+
# A SIGINT mid-turn cancels the asyncio.run loop, surfacing as
|
|
2507
|
+
# CancelledError here (typer does not convert it). Print the
|
|
2508
|
+
# one-line abort notice — not the 40-line traceback the framework
|
|
2509
|
+
# excepthook logs — and exit with the conventional SIGINT code so
|
|
2510
|
+
# shells read "cancelled", not "crashed". Raw mode keeps stdout
|
|
2511
|
+
# clean for pipelines: the notice goes to stderr like all other
|
|
2512
|
+
# activity output.
|
|
2513
|
+
abort_console = Console(stderr=True) if raw else console
|
|
2514
|
+
abort_console.print("[red]⏹ Turn aborted by operator (Ctrl-C).[/red]")
|
|
2515
|
+
raise typer.Exit(code=_ABORT_EXIT) from None
|
|
2516
|
+
if transport.approval_unresolved:
|
|
2517
|
+
# The approval request id is durable; the operator must decide it in
|
|
2518
|
+
# an interactive session. Exit non-zero so pipelines never mistake an
|
|
2519
|
+
# un-decided approval for a completed turn.
|
|
2520
|
+
raise typer.Exit(code=_APPROVAL_REQUIRED_EXIT)
|
|
2521
|
+
if transport.yield_unresolved:
|
|
2522
|
+
# The model ended in a yield (ask-the-operator). The question was
|
|
2523
|
+
# printed to stderr; stdout got nothing. Exit non-zero so pipelines
|
|
2524
|
+
# never mistake the question for the requested artifact.
|
|
2525
|
+
raise typer.Exit(code=_YIELD_UNRESOLVED_EXIT)
|
|
2526
|
+
if restart_required:
|
|
2527
|
+
_exit_restart_required()
|
|
2528
|
+
|
|
2529
|
+
|
|
2530
|
+
async def _run_prompt_with_teardown(
|
|
2531
|
+
runtime: HostRuntime,
|
|
2532
|
+
message: str,
|
|
2533
|
+
*,
|
|
2534
|
+
quiet: bool = False,
|
|
2535
|
+
raw: bool = False,
|
|
2536
|
+
tool_detail: int = 0,
|
|
2537
|
+
streaming_mode: str = "none",
|
|
2538
|
+
decision: _PrearmedDecision | None = None,
|
|
2539
|
+
) -> tuple[bool, PromptTransport]:
|
|
2540
|
+
"""Run one turn (and any pre-armed decision), then close the MCP pool.
|
|
2541
|
+
|
|
2542
|
+
A pre-armed decision (``--approve``/``--deny``) is dispatched FIRST,
|
|
2543
|
+
through the typed approval core — one dispatch, one value, no command
|
|
2544
|
+
string — so the pending approval the prior run left behind is settled
|
|
2545
|
+
before the turn runs with it. ``--remember`` persists an allow rule for
|
|
2546
|
+
the decided prefix (default prefix when no explicit value is given).
|
|
2547
|
+
|
|
2548
|
+
Returns ``(restart_required, transport)`` — the transport is returned so
|
|
2549
|
+
the caller can inspect post-turn state (``approval_unresolved``).
|
|
2550
|
+
|
|
2551
|
+
In raw mode the transport console moves to stderr so activity output
|
|
2552
|
+
(λ echo, tool lines, ⚡ footer, errors) never lands in redirected stdout;
|
|
2553
|
+
the response itself is written verbatim to stdout by the transport.
|
|
2554
|
+
"""
|
|
2555
|
+
from runtime.handle_message import ( # lazy: defers turn import cost
|
|
2556
|
+
decide_pending_approval,
|
|
2557
|
+
handle_message,
|
|
2558
|
+
)
|
|
2559
|
+
|
|
2560
|
+
from hosts.cli.transport import PromptTransport # lazy: defers transport import
|
|
2561
|
+
|
|
2562
|
+
transport_console: Console = Console(stderr=True) if raw else console
|
|
2563
|
+
transport = PromptTransport(
|
|
2564
|
+
console=transport_console,
|
|
2565
|
+
quiet=quiet,
|
|
2566
|
+
raw=raw,
|
|
2567
|
+
tool_detail=tool_detail,
|
|
2568
|
+
streaming_mode=streaming_mode,
|
|
2569
|
+
)
|
|
2570
|
+
transport.model_prices = _collect_model_prices(runtime)
|
|
2571
|
+
transport.model_cache_prices = _collect_model_cache_prices(runtime)
|
|
2572
|
+
transport.start_turn(message)
|
|
2573
|
+
try:
|
|
2574
|
+
await runtime.start()
|
|
2575
|
+
if decision is not None:
|
|
2576
|
+
request_id = decision.request_id
|
|
2577
|
+
if not request_id:
|
|
2578
|
+
request_id = await _resolve_pending_request_id(runtime)
|
|
2579
|
+
result = await decide_pending_approval(
|
|
2580
|
+
runtime,
|
|
2581
|
+
request_id,
|
|
2582
|
+
approve=decision.approve,
|
|
2583
|
+
# --remember is a value flag here: explicit prefix or none.
|
|
2584
|
+
remember=decision.remember or None,
|
|
2585
|
+
remember_default=False,
|
|
2586
|
+
transport=transport,
|
|
2587
|
+
)
|
|
2588
|
+
if not message:
|
|
2589
|
+
# Decision-only run: surface the decision's reply text and
|
|
2590
|
+
# stop — there is no turn to run.
|
|
2591
|
+
decision_text = getattr(result, "text", "") or str(result)
|
|
2592
|
+
transport.stop_activity()
|
|
2593
|
+
await runtime.aclose()
|
|
2594
|
+
await transport.on_response(decision_text)
|
|
2595
|
+
return runtime.session.restart_required, transport
|
|
2596
|
+
if message:
|
|
2597
|
+
await handle_message(runtime, message, transport)
|
|
2598
|
+
finally:
|
|
2599
|
+
transport.stop_activity()
|
|
2600
|
+
await runtime.aclose()
|
|
2601
|
+
return runtime.session.restart_required, transport
|
|
2602
|
+
|
|
2603
|
+
|
|
2604
|
+
def _collect_model_prices(runtime: HostRuntime) -> dict[str, tuple[float, float]]:
|
|
2605
|
+
"""Read per-model per-token USD prices from the model manifest.
|
|
2606
|
+
|
|
2607
|
+
Returns ``{model_id: (prompt_price, completion_price)}`` — the same
|
|
2608
|
+
fields ``render_models``'s Cost column formats. Empty dict on any
|
|
2609
|
+
failure: the transport omits the cost footer rather than blocking the
|
|
2610
|
+
turn on an unreadable manifest.
|
|
2611
|
+
"""
|
|
2612
|
+
try:
|
|
2613
|
+
from runtime.model_manifest import load_model_manifest
|
|
2614
|
+
|
|
2615
|
+
manifest = load_model_manifest(runtime.paths.models_path)
|
|
2616
|
+
except Exception: # pylint: disable=broad-except
|
|
2617
|
+
return {}
|
|
2618
|
+
prices: dict[str, tuple[float, float]] = {}
|
|
2619
|
+
for model in manifest.models:
|
|
2620
|
+
prompt_price = float(getattr(model, "prompt_price", 0.0) or 0.0)
|
|
2621
|
+
completion_price = float(getattr(model, "completion_price", 0.0) or 0.0)
|
|
2622
|
+
if prompt_price > 0.0 or completion_price > 0.0:
|
|
2623
|
+
prices[str(model.model_id)] = (prompt_price, completion_price)
|
|
2624
|
+
return prices
|
|
2625
|
+
|
|
2626
|
+
|
|
2627
|
+
def _collect_model_cache_prices(runtime: HostRuntime) -> dict[str, tuple[float, float]]:
|
|
2628
|
+
"""Read per-model per-token USD prompt-cache prices from the manifest.
|
|
2629
|
+
|
|
2630
|
+
Returns ``{model_id: (cache_read_price, cache_write_price)}``. Only
|
|
2631
|
+
models with a nonzero read and/or write price appear; missing keys make
|
|
2632
|
+
the footer's estimate treat every prompt as uncached (legacy behavior).
|
|
2633
|
+
"""
|
|
2634
|
+
try:
|
|
2635
|
+
from runtime.model_manifest import load_model_manifest
|
|
2636
|
+
|
|
2637
|
+
manifest = load_model_manifest(runtime.paths.models_path)
|
|
2638
|
+
except Exception: # pylint: disable=broad-except
|
|
2639
|
+
return {}
|
|
2640
|
+
prices: dict[str, tuple[float, float]] = {}
|
|
2641
|
+
for model in manifest.models:
|
|
2642
|
+
read_price = float(getattr(model, "cache_read_price", 0.0) or 0.0)
|
|
2643
|
+
write_price = float(getattr(model, "cache_write_price", 0.0) or 0.0)
|
|
2644
|
+
if read_price > 0.0 or write_price > 0.0:
|
|
2645
|
+
prices[str(model.model_id)] = (read_price, write_price)
|
|
2646
|
+
return prices
|
|
2647
|
+
|
|
2648
|
+
|
|
2649
|
+
# ---------------------------------------------------------------------------
|
|
2650
|
+
# intake command
|
|
2651
|
+
# ---------------------------------------------------------------------------
|
|
2652
|
+
|
|
2653
|
+
|
|
2654
|
+
@app.command("intake", help="Run one intake tick: collect signals, process, write state.")
|
|
2655
|
+
def _intake_command(
|
|
2656
|
+
data_dir: DataDirOption = "",
|
|
2657
|
+
profile: ProfileOption = "",
|
|
2658
|
+
log_level: LogLevelOption = "",
|
|
2659
|
+
host_log: HostLogOption = "",
|
|
2660
|
+
) -> None:
|
|
2661
|
+
"""One-shot intake process. Triggered by systemd timer or cron.
|
|
2662
|
+
|
|
2663
|
+
Collects signals from installed pack signal connectors, runs processing
|
|
2664
|
+
loops, and writes the merged UnifiedState to ``<data-dir>/state/state.json``.
|
|
2665
|
+
The next turn reads that state so the model has fresh ambient data.
|
|
2666
|
+
"""
|
|
2667
|
+
resolved_data_dir = (
|
|
2668
|
+
Path(_resolve_profiled_data_dir(data_dir, profile)).expanduser()
|
|
2669
|
+
if _resolve_profiled_data_dir(data_dir, profile)
|
|
2670
|
+
else default_data_dir()
|
|
2671
|
+
)
|
|
2672
|
+
try:
|
|
2673
|
+
from runtime.intake import run_intake
|
|
2674
|
+
except ImportError:
|
|
2675
|
+
console.print("[red]intake module not available in this host.[/red]")
|
|
2676
|
+
raise typer.Exit(code=2) from None
|
|
2677
|
+
|
|
2678
|
+
state = asyncio.run(run_intake(data_dir=resolved_data_dir))
|
|
2679
|
+
console.print(
|
|
2680
|
+
f"[green]Intake tick complete.[/green] "
|
|
2681
|
+
f"{len(state.context_items)} context item(s). "
|
|
2682
|
+
f"State written to {resolved_data_dir / 'state' / 'state.json'}"
|
|
2683
|
+
)
|
|
2684
|
+
|
|
2685
|
+
|
|
2686
|
+
def main() -> None:
|
|
2687
|
+
"""CLI entry point for the Lisa terminal host."""
|
|
2688
|
+
app(args=list(sys.argv[1:]))
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
if __name__ == "__main__":
|
|
2692
|
+
main()
|