Coverage for little_loops / host_runner.py: 92%
225 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
1"""Host CLI abstraction layer.
3Provides a host-agnostic ``HostRunner`` Protocol and a concrete
4``ClaudeCodeRunner`` implementation that builds argv for the ``claude`` CLI.
5``resolve_host()`` discovers the active host runner from environment overrides
6or by probing for known host binaries on ``PATH``.
8This module is the foundation for FEAT-1464's call-site migrations
9(FEAT-1468): production sites currently building ``claude`` argv inline will
10consult ``resolve_host()`` and use the returned runner's factory methods to
11build invocations. FEAT-1467 introduces only the abstraction and its
12ClaudeCode implementation; no production sites are migrated yet.
14Public exports:
15 HostInvocation: frozen value object describing a host invocation
16 HostCapabilities: capability flags describing what a host supports
17 HostRunner: Protocol that host runners must satisfy
18 ClaudeCodeRunner: ClaudeCode CLI implementation
19 resolve_host: discovery entry point
20 HostNotConfigured: raised when no host can be resolved
21 CapabilityNotSupported: warning emitted when a host lacks a capability
22"""
24from __future__ import annotations
26import json
27import shutil
28import sys
29import tempfile
30import tomllib
31import warnings
32from dataclasses import dataclass, field
33from pathlib import Path
34from typing import Literal, Protocol, runtime_checkable
36__all__ = [
37 "CapabilityEntry",
38 "CapabilityNotSupported",
39 "CapabilityReport",
40 "ClaudeCodeRunner",
41 "CodexRunner",
42 "HookEntry",
43 "HostCapabilities",
44 "HostInvocation",
45 "HostNotConfigured",
46 "HostRunner",
47 "OpenCodeRunner",
48 "PiRunner",
49 "apply_host_cli_from_config",
50 "resolve_host",
51]
54class HostNotConfigured(RuntimeError):
55 """Raised when no host runner can be resolved from env or binary probe.
57 The error message includes a remediation hint pointing at the
58 ``LL_HOST_CLI`` and ``LL_HOOK_HOST`` env vars and the ``orchestration.host_cli``
59 config key so users have a clear path to fix the failure.
60 """
63class CapabilityNotSupported(UserWarning):
64 """Emitted when a caller requests a capability the active host lacks.
66 Subclasses ``UserWarning`` (not ``Warning``) so test code can capture it
67 via :func:`pytest.warns` and production code can route it through
68 :func:`warnings.simplefilter("error", CapabilityNotSupported)` for strict
69 contexts. Mirrors the precedent set by :mod:`config.core` which emits
70 :class:`DeprecationWarning` via ``warnings.warn(..., stacklevel=2)``.
71 """
74@dataclass(frozen=True)
75class HostCapabilities:
76 """Capability flags describing what a host runner supports.
78 Each flag corresponds to a feature that may or may not be available on
79 a given host. Call sites that require a capability should check the
80 relevant flag and either fall back gracefully or emit
81 :class:`CapabilityNotSupported`.
82 """
84 streaming: bool = False
85 permission_skip: bool = False
86 agent_select: bool = False
87 tool_allowlist: bool = False
90@dataclass(frozen=True)
91class HostInvocation:
92 """Immutable description of how to invoke a host CLI.
94 Returned by the ``build_*`` factory methods on :class:`HostRunner`. Call
95 sites pass ``binary`` + ``args`` to :mod:`subprocess` and merge ``env``
96 into the child process environment. ``capabilities`` records the host's
97 capability surface so callers can branch on what was actually wired.
99 Frozen because instances cross the runner/caller boundary; mutating one
100 in-flight would silently corrupt argv. This establishes the
101 ``frozen=True`` convention for new value objects in ``scripts/little_loops/``.
102 """
104 binary: str
105 args: list[str]
106 env: dict[str, str] = field(default_factory=dict)
107 capabilities: HostCapabilities = field(default_factory=HostCapabilities)
108 cleanup_paths: tuple[Path, ...] = field(default_factory=tuple)
111@dataclass(frozen=True)
112class CapabilityEntry:
113 """A single host capability with its support status.
115 ``status`` is one of ``"full"``, ``"partial"``, or ``"unsupported"``.
116 ``note`` carries an optional human-readable explanation (e.g. workaround).
117 """
119 name: str
120 status: Literal["full", "partial", "unsupported"]
121 note: str = ""
124@dataclass(frozen=True)
125class HookEntry:
126 """A single hook's installation status for a given host.
128 ``status`` is one of ``"installed"``, ``"registered"``, ``"deferred"``, or ``"absent"``.
129 """
131 name: str
132 status: Literal["installed", "registered", "deferred", "absent"]
133 note: str = ""
136@dataclass(frozen=True)
137class CapabilityReport:
138 """Full capability and hook report for one host runner.
140 Returned by :meth:`HostRunner.describe_capabilities`. Consumers (e.g. the
141 ``ll-doctor`` CLI) iterate ``capabilities`` and ``hooks`` to produce a
142 tabular preflight report.
143 """
145 host: str
146 binary: str
147 version: str
148 capabilities: list[CapabilityEntry] = field(default_factory=list)
149 hooks: list[HookEntry] = field(default_factory=list)
152@runtime_checkable
153class HostRunner(Protocol):
154 """Protocol for host-specific CLI invocation builders.
156 Implementations construct argv lists for a particular agent host (Claude
157 Code, Codex, OpenCode, Pi, ...). Each ``build_*`` factory returns a
158 :class:`HostInvocation` describing the binary, arguments, environment,
159 and capability surface.
161 Protocols are matched structurally — any class with a ``name`` attribute
162 and the five methods below satisfies ``HostRunner`` whether or not it
163 subclasses this Protocol explicitly. ``@runtime_checkable`` enables
164 ``isinstance(obj, HostRunner)`` checks for registry validation.
165 """
167 name: str
169 def detect(self) -> bool:
170 """Return True if this host is available in the current environment."""
171 ...
173 def build_streaming(
174 self,
175 *,
176 prompt: str,
177 working_dir: Path | None = None,
178 resume: bool = False,
179 agent: str | None = None,
180 tools: list[str] | None = None,
181 ) -> HostInvocation:
182 """Build an invocation that streams structured output.
184 Used by the long-running orchestration paths (``ll-auto``, ``ll-parallel``,
185 ``fsm.runners``) that need to consume turn-by-turn JSON events.
186 """
187 ...
189 def build_blocking_json(
190 self,
191 *,
192 prompt: str,
193 model: str | None = None,
194 json_schema: dict | None = None,
195 ) -> HostInvocation:
196 """Build a one-shot invocation that returns a single JSON blob."""
197 ...
199 def build_version_check(self) -> HostInvocation:
200 """Build an invocation that prints the host's version and exits."""
201 ...
203 def build_detached(self, *, prompt: str) -> HostInvocation:
204 """Build an invocation suitable for fire-and-forget detached execution."""
205 ...
207 def describe_capabilities(self) -> CapabilityReport:
208 """Return a structured capability and hook report for this host."""
209 ...
212class ClaudeCodeRunner:
213 """``HostRunner`` for the ``claude`` CLI (Claude Code).
215 The argv produced by :meth:`build_streaming` mirrors
216 :func:`little_loops.subprocess_utils.run_claude_command` so existing
217 behavior is preserved when FEAT-1468 migrates call sites. The version
218 snapshot lives in ``tests/test_host_runner.py::test_claude_runner_matches_legacy_args``.
219 """
221 name = "claude-code"
223 capabilities = HostCapabilities(
224 streaming=True,
225 permission_skip=True,
226 agent_select=True,
227 tool_allowlist=True,
228 )
230 def detect(self) -> bool:
231 return shutil.which("claude") is not None
233 def build_streaming(
234 self,
235 *,
236 prompt: str,
237 working_dir: Path | None = None,
238 resume: bool = False,
239 agent: str | None = None,
240 tools: list[str] | None = None,
241 ) -> HostInvocation:
242 args: list[str] = [
243 "--dangerously-skip-permissions",
244 "--verbose",
245 "--output-format",
246 "stream-json",
247 ]
248 if resume:
249 args.append("--continue")
250 args += ["-p", prompt]
251 if agent:
252 args += ["--agent", agent]
253 if tools:
254 args += ["--tools", ",".join(tools)]
256 env: dict[str, str] = {"CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1"}
257 if working_dir is not None:
258 git_path = Path(working_dir) / ".git"
259 if git_path.is_file():
260 gitdir_ref = git_path.read_text().strip()
261 if gitdir_ref.startswith("gitdir: "):
262 actual_gitdir = gitdir_ref[8:].strip()
263 resolved = (Path(working_dir) / actual_gitdir).resolve()
264 env["GIT_DIR"] = str(resolved)
265 env["GIT_WORK_TREE"] = str(working_dir)
267 return HostInvocation(
268 binary="claude",
269 args=args,
270 env=env,
271 capabilities=self.capabilities,
272 )
274 def build_blocking_json(
275 self,
276 *,
277 prompt: str,
278 model: str | None = None,
279 json_schema: dict | None = None,
280 ) -> HostInvocation:
281 args: list[str] = [
282 "--dangerously-skip-permissions",
283 "--output-format",
284 "json",
285 "-p",
286 prompt,
287 ]
288 if model:
289 args += ["--model", model]
290 # json_schema is part of the Protocol surface so other hosts can wire
291 # structured-output constraints; the claude CLI does not currently
292 # accept a schema flag, so we silently drop it here. Callers that
293 # require schema enforcement should warn via CapabilityNotSupported.
294 _ = json_schema
295 return HostInvocation(
296 binary="claude",
297 args=args,
298 env={},
299 capabilities=self.capabilities,
300 )
302 def build_version_check(self) -> HostInvocation:
303 return HostInvocation(
304 binary="claude",
305 args=["--version"],
306 env={},
307 capabilities=self.capabilities,
308 )
310 def build_detached(self, *, prompt: str) -> HostInvocation:
311 args = [
312 "--dangerously-skip-permissions",
313 "-p",
314 prompt,
315 ]
316 return HostInvocation(
317 binary="claude",
318 args=args,
319 env={},
320 capabilities=self.capabilities,
321 )
323 def describe_capabilities(self) -> CapabilityReport:
324 return CapabilityReport(
325 host=self.name,
326 binary="claude",
327 version="",
328 capabilities=[
329 CapabilityEntry("streaming", "full"),
330 CapabilityEntry("permission_skip", "full"),
331 CapabilityEntry("agent_select", "full"),
332 CapabilityEntry("tool_allowlist", "full"),
333 # build_blocking_json silently drops json_schema (no Codex-style warning)
334 CapabilityEntry(
335 "json_schema",
336 "unsupported",
337 "claude CLI does not accept an inline schema flag; parameter is silently dropped",
338 ),
339 ],
340 )
343class CodexRunner:
344 """``HostRunner`` for the ``codex`` CLI (OpenAI Codex, Rust-based GA build).
346 Translates the Claude-shaped Protocol surface to Codex's ``codex exec``
347 headless mode. See ``thoughts/research/codex-headless-invocation.md`` for
348 the verified flag-translation table and source citations.
350 Key divergences from :class:`ClaudeCodeRunner`:
352 - Prompt is **positional** (``codex exec <prompt>``); Claude's ``-p`` maps
353 to Codex ``--profile``, so we cannot reuse the same flag.
354 - The combined ``--dangerously-bypass-approvals-and-sandbox`` flag is the
355 1:1 equivalent of Claude's ``--dangerously-skip-permissions`` (skips
356 both approval prompt and sandbox restrictions).
357 - Codex has no single-blob JSON mode; ``--json`` always streams NDJSON
358 events. ``build_blocking_json`` uses ``--json`` and callers must consume
359 the final event.
360 - Agent selection (``--agent``) has no CLI-flag equivalent in Codex.
361 Codex *does* support custom subagents
362 (`developers.openai.com/codex/subagents`_) defined in
363 ``.codex/agents/*.toml``, but they are spawned by the model during a
364 conversation rather than selected by the caller at invocation. The
365 ``agent`` parameter is therefore dropped with a
366 :class:`CapabilityNotSupported` warning; to get persona behavior under
367 Codex, ship native ``.codex/agents/*.toml`` files via
368 ``ll-adapt-agents-for-codex`` (mirrors ``ll-adapt-skills-for-codex``).
369 - Tool allowlist (``--tools``) has no Codex equivalent — Codex routes
370 tool access through sandbox modes, and ``--profile`` is for auth, not
371 persona. Emits :class:`CapabilityNotSupported` when requested. The
372 warnings here deliberately diverge from
373 :class:`ClaudeCodeRunner.build_blocking_json` which silently drops
374 ``json_schema``; FEAT-1465 AC requires the warning be emitted here so
375 callers can degrade explicitly.
377 .. _developers.openai.com/codex/subagents:
378 https://developers.openai.com/codex/subagents
379 - Resume restructures the subcommand to ``codex exec resume --last`` per
380 Codex CLI reference, rather than appending a ``--continue`` flag.
381 """
383 name = "codex"
385 capabilities = HostCapabilities(
386 streaming=True,
387 permission_skip=True,
388 agent_select=False,
389 tool_allowlist=False,
390 )
392 def detect(self) -> bool:
393 return shutil.which("codex") is not None
395 @staticmethod
396 def _emit_agent_warning(agent: str) -> None:
397 # Note: this stderr print writes to the parent process's sys.stderr.
398 # `subprocess_utils.run_claude_command()`'s `stream_callback(is_stderr=True)`
399 # captures the spawned subprocess's stderr only — programmatic stream
400 # consumers will not see this message; interactive terminals will.
401 warnings.warn(
402 "codex has no CLI-flag agent selection. Codex subagents "
403 "(.codex/agents/*.toml) exist but are spawned by the model "
404 "during a conversation, not selected at invocation. The "
405 "'agent' parameter will be ignored; ship native Codex agent "
406 "files for persona behavior under this host.",
407 CapabilityNotSupported,
408 stacklevel=3,
409 )
410 print(
411 f"[ll] Warning: Codex does not support --agent at invocation time (ENH-1531).\n"
412 f" Persona hint {agent!r} was dropped. For interactive sessions,\n"
413 f" run `ll-adapt-agents-for-codex --apply` and use `--agent {agent}`\n"
414 f" in the Codex TUI.",
415 file=sys.stderr,
416 )
418 @staticmethod
419 def _inject_agent_persona(
420 agent: str, prompt: str, working_dir: Path | None
421 ) -> tuple[str, bool]:
422 base = Path(working_dir) if working_dir is not None else Path.cwd()
423 toml_path = base / ".codex" / "agents" / f"{agent}.toml"
424 if not toml_path.exists():
425 return prompt, False
426 try:
427 data = tomllib.loads(toml_path.read_text())
428 except (OSError, tomllib.TOMLDecodeError):
429 return prompt, False
430 instructions = str(data.get("developer_instructions", "")).strip()
431 if not instructions:
432 return prompt, False
433 return f"[Persona: {agent}]\n{instructions}\n\n---\n\n{prompt}", True
435 def build_streaming(
436 self,
437 *,
438 prompt: str,
439 working_dir: Path | None = None,
440 resume: bool = False,
441 agent: str | None = None,
442 tools: list[str] | None = None,
443 ) -> HostInvocation:
444 if agent is not None:
445 prompt, injected = self._inject_agent_persona(agent, prompt, working_dir)
446 if not injected:
447 self._emit_agent_warning(agent)
448 if tools:
449 warnings.warn(
450 "codex host does not support a tool allowlist; "
451 "tool access is controlled via --sandbox mode. "
452 "The 'tools' parameter will be ignored.",
453 CapabilityNotSupported,
454 stacklevel=2,
455 )
457 args: list[str] = ["exec"]
458 if resume:
459 args += ["resume", "--last"]
460 args += [
461 "--dangerously-bypass-approvals-and-sandbox",
462 "--json",
463 "--skip-git-repo-check",
464 ]
465 if working_dir is not None:
466 args += ["-C", str(working_dir)]
467 args.append(prompt)
469 env: dict[str, str] = {}
470 if working_dir is not None:
471 git_path = Path(working_dir) / ".git"
472 if git_path.is_file():
473 gitdir_ref = git_path.read_text().strip()
474 if gitdir_ref.startswith("gitdir: "):
475 actual_gitdir = gitdir_ref[8:].strip()
476 resolved = (Path(working_dir) / actual_gitdir).resolve()
477 env["GIT_DIR"] = str(resolved)
478 env["GIT_WORK_TREE"] = str(working_dir)
480 return HostInvocation(
481 binary="codex",
482 args=args,
483 env=env,
484 capabilities=self.capabilities,
485 )
487 def build_blocking_json(
488 self,
489 *,
490 prompt: str,
491 model: str | None = None,
492 json_schema: dict | None = None,
493 ) -> HostInvocation:
494 args: list[str] = [
495 "exec",
496 "--dangerously-bypass-approvals-and-sandbox",
497 "--json",
498 "--skip-git-repo-check",
499 ]
500 if model:
501 args += ["--model", model]
503 cleanup: tuple[Path, ...] = ()
504 if json_schema is not None:
505 with tempfile.NamedTemporaryFile(
506 delete=False, suffix=".json", prefix="ll-schema-", mode="w"
507 ) as f:
508 json.dump(json_schema, f)
509 schema_file = Path(f.name)
510 args += ["--output-schema", str(schema_file)]
511 cleanup = (schema_file,)
513 args.append(prompt)
514 return HostInvocation(
515 binary="codex",
516 args=args,
517 env={},
518 capabilities=self.capabilities,
519 cleanup_paths=cleanup,
520 )
522 def build_version_check(self) -> HostInvocation:
523 return HostInvocation(
524 binary="codex",
525 args=["--version"],
526 env={},
527 capabilities=self.capabilities,
528 )
530 def build_detached(self, *, prompt: str) -> HostInvocation:
531 args = [
532 "exec",
533 "--dangerously-bypass-approvals-and-sandbox",
534 "--skip-git-repo-check",
535 prompt,
536 ]
537 return HostInvocation(
538 binary="codex",
539 args=args,
540 env={},
541 capabilities=self.capabilities,
542 )
544 def describe_capabilities(self) -> CapabilityReport:
545 return CapabilityReport(
546 host=self.name,
547 binary="codex",
548 version="",
549 capabilities=[
550 CapabilityEntry("streaming", "full"),
551 CapabilityEntry("permission_skip", "full"),
552 # agent_select=False bool stays False (no native --agent CLI flag),
553 # but status is "partial" because build_streaming injects persona via
554 # .codex/agents/<name>.toml `developer_instructions` when present.
555 # Fallback path (TOML absent) emits CapabilityNotSupported + stderr notice.
556 CapabilityEntry(
557 "agent_select",
558 "partial",
559 "codex has no native --agent CLI flag; build_streaming injects "
560 "`developer_instructions` from .codex/agents/<name>.toml into the "
561 "prompt as a persona prefix when the file exists. Falls back to "
562 "CapabilityNotSupported + stderr warning when the TOML is absent.",
563 ),
564 # tool_allowlist=False (line 304); warning at build_streaming lines 326-333
565 CapabilityEntry(
566 "tool_allowlist",
567 "unsupported",
568 "codex uses sandbox modes for tool access; --tools parameter is ignored",
569 ),
570 # json_schema: partial — --output-schema requires a file path; ENH-1530 bridges
571 # via temp file written in build_blocking_json, path returned in cleanup_paths
572 CapabilityEntry(
573 "json_schema",
574 "partial",
575 "codex --output-schema requires a file path; schema is written to a "
576 "temp file and path returned in HostInvocation.cleanup_paths for caller cleanup",
577 ),
578 ],
579 )
582class OpenCodeRunner:
583 """``HostRunner`` stub for the ``opencode`` CLI (FEAT-1472, Option B).
585 No external CLI research has been performed. Every ``build_*`` method
586 raises :class:`HostNotConfigured` pointing callers at
587 ``LL_HOST_CLI=claude-code``. Registration in ``_HOST_RUNNER_REGISTRY``
588 means an explicit ``LL_HOST_CLI=opencode`` resolves to a useful error
589 message rather than the generic "unknown host" error. The runner is
590 deliberately absent from ``_PROBE_ORDER`` so no auto-detection occurs.
591 """
593 name = "opencode"
595 capabilities = HostCapabilities()
597 def detect(self) -> bool:
598 return shutil.which("opencode") is not None
600 def build_streaming(
601 self,
602 *,
603 prompt: str,
604 working_dir: Path | None = None,
605 resume: bool = False,
606 agent: str | None = None,
607 tools: list[str] | None = None,
608 ) -> HostInvocation:
609 raise HostNotConfigured(
610 "OpenCode orchestration not yet wired — research OpenCode headless CLI. "
611 "Set LL_HOST_CLI=claude-code to use Claude Code instead."
612 )
614 def build_blocking_json(
615 self,
616 *,
617 prompt: str,
618 model: str | None = None,
619 json_schema: dict | None = None,
620 ) -> HostInvocation:
621 raise HostNotConfigured(
622 "OpenCode orchestration not yet wired — research OpenCode headless CLI. "
623 "Set LL_HOST_CLI=claude-code to use Claude Code instead."
624 )
626 def build_version_check(self) -> HostInvocation:
627 raise HostNotConfigured(
628 "OpenCode orchestration not yet wired — research OpenCode headless CLI. "
629 "Set LL_HOST_CLI=claude-code to use Claude Code instead."
630 )
632 def build_detached(self, *, prompt: str) -> HostInvocation:
633 raise HostNotConfigured(
634 "OpenCode orchestration not yet wired — research OpenCode headless CLI. "
635 "Set LL_HOST_CLI=claude-code to use Claude Code instead."
636 )
638 def describe_capabilities(self) -> CapabilityReport:
639 return CapabilityReport(
640 host=self.name,
641 binary="opencode",
642 version="",
643 capabilities=[
644 CapabilityEntry(
645 "host",
646 "unsupported",
647 "binary not configured (HostNotConfigured) — opencode orchestration not yet wired",
648 )
649 ],
650 )
653class PiRunner:
654 """``HostRunner`` stub for the ``pi`` CLI (FEAT-1472).
656 Pi orchestration is tracked under FEAT-992; until that lands, all four
657 ``build_*`` methods raise :class:`HostNotConfigured`. Unlike
658 :class:`OpenCodeRunner`, ``("pi", "pi")`` is already present in
659 ``_PROBE_ORDER`` (added in FEAT-1464), so registering ``PiRunner`` now
660 activates that probe edge: hosts with ``pi`` on PATH will resolve to
661 this stub and raise on the first ``build_*`` call.
662 """
664 name = "pi"
666 capabilities = HostCapabilities()
668 def detect(self) -> bool:
669 return shutil.which("pi") is not None
671 def build_streaming(
672 self,
673 *,
674 prompt: str,
675 working_dir: Path | None = None,
676 resume: bool = False,
677 agent: str | None = None,
678 tools: list[str] | None = None,
679 ) -> HostInvocation:
680 raise HostNotConfigured(
681 "Pi orchestration not yet wired — see FEAT-992. "
682 "Set LL_HOST_CLI=claude-code to use Claude Code instead."
683 )
685 def build_blocking_json(
686 self,
687 *,
688 prompt: str,
689 model: str | None = None,
690 json_schema: dict | None = None,
691 ) -> HostInvocation:
692 raise HostNotConfigured(
693 "Pi orchestration not yet wired — see FEAT-992. "
694 "Set LL_HOST_CLI=claude-code to use Claude Code instead."
695 )
697 def build_version_check(self) -> HostInvocation:
698 raise HostNotConfigured(
699 "Pi orchestration not yet wired — see FEAT-992. "
700 "Set LL_HOST_CLI=claude-code to use Claude Code instead."
701 )
703 def build_detached(self, *, prompt: str) -> HostInvocation:
704 raise HostNotConfigured(
705 "Pi orchestration not yet wired — see FEAT-992. "
706 "Set LL_HOST_CLI=claude-code to use Claude Code instead."
707 )
709 def describe_capabilities(self) -> CapabilityReport:
710 return CapabilityReport(
711 host=self.name,
712 binary="pi",
713 version="",
714 capabilities=[
715 CapabilityEntry(
716 "host",
717 "unsupported",
718 "binary not configured (HostNotConfigured) — see FEAT-992",
719 )
720 ],
721 )
724# Built-in host runners keyed by their ``name`` attribute. Extensions may
725# register additional runners but built-ins always win on collision —
726# mirrors ``hooks/__init__.py:_dispatch_table`` (built-ins shadow extensions).
727_HOST_RUNNER_REGISTRY: dict[str, type[HostRunner]] = {
728 "claude-code": ClaudeCodeRunner,
729 "codex": CodexRunner,
730 "opencode": OpenCodeRunner,
731 "pi": PiRunner,
732}
734# Order of probing when no explicit host is configured. Matches the binary
735# names users typically have on PATH; extends as new runners land.
736_PROBE_ORDER: list[tuple[str, str]] = [
737 ("claude-code", "claude"),
738 ("codex", "codex"),
739 ("pi", "pi"),
740]
743def _remediation_hint() -> str:
744 return (
745 "Set LL_HOST_CLI=<host> (one of: claude-code, codex, opencode, pi), "
746 "or LL_HOOK_HOST, or configure orchestration.host_cli in ll-config.json, "
747 "or install a supported host CLI on PATH (claude, codex, or pi)."
748 )
751def resolve_host(env: dict[str, str] | None = None) -> HostRunner:
752 """Resolve the active :class:`HostRunner`.
754 Detection order (first match wins):
756 1. ``LL_HOST_CLI`` environment variable — explicit override.
757 2. ``LL_HOOK_HOST`` environment variable — falls back to the hooks-layer
758 host identifier so users with an existing hook config don't need a
759 second knob.
760 3. Binary probe: ``claude`` → ``codex`` → ``pi`` (see ``_PROBE_ORDER``).
761 4. Raise :class:`HostNotConfigured` with a remediation hint.
763 Args:
764 env: Optional environment dict for testability. Defaults to
765 ``os.environ`` when omitted.
767 Returns:
768 A :class:`HostRunner` instance ready to build invocations.
770 Raises:
771 HostNotConfigured: if no host can be resolved.
772 """
773 import os
775 if env is None:
776 env = dict(os.environ)
778 explicit = env.get("LL_HOST_CLI") or env.get("LL_HOOK_HOST")
779 if explicit:
780 runner_cls = _HOST_RUNNER_REGISTRY.get(explicit)
781 if runner_cls is not None:
782 return runner_cls()
783 raise HostNotConfigured(
784 f"Host {explicit!r} is not registered. Available: "
785 f"{sorted(_HOST_RUNNER_REGISTRY)}. {_remediation_hint()}"
786 )
788 for host_name, binary in _PROBE_ORDER:
789 if shutil.which(binary) is None:
790 continue
791 runner_cls = _HOST_RUNNER_REGISTRY.get(host_name)
792 if runner_cls is not None:
793 return runner_cls()
795 raise HostNotConfigured(f"No host CLI detected on PATH. {_remediation_hint()}")
798def apply_host_cli_from_config(config: object) -> None:
799 """Export ``orchestration.host_cli`` from *config* as ``LL_HOST_CLI``.
801 Reads ``config.orchestration.host_cli`` (a :class:`~little_loops.config.OrchestrationConfig`
802 attribute) and sets ``LL_HOST_CLI`` in the process environment so that a
803 subsequent call to :func:`resolve_host` picks up the config-driven value.
805 The env var takes precedence if already set — callers that set ``LL_HOST_CLI``
806 explicitly in their environment are not overridden. This matches the
807 documented resolution order (env var > config key > binary probe).
809 Args:
810 config: A :class:`~little_loops.config.BRConfig` instance (typed as
811 ``object`` to avoid a circular import; the attribute access pattern
812 is ``config.orchestration.host_cli``).
813 """
814 import os
816 if os.environ.get("LL_HOST_CLI"):
817 return # explicit env override takes precedence
818 try:
819 host_cli: str | None = config.orchestration.host_cli # type: ignore[attr-defined]
820 except AttributeError:
821 return # config object doesn't support orchestration (e.g. tests)
822 if host_cli:
823 os.environ["LL_HOST_CLI"] = host_cli