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/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()