Coverage for src/lilbee/cli/launchers/opencode.py: 100%
59 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Opencode launcher: wires the inline config, installs the skill, runs opencode."""
3from __future__ import annotations
5import json
6import os
7import shutil
8import sys
9from pathlib import Path
11import typer
13from lilbee.app.agent_configs.merge import deep_merge, prune_lilbee
14from lilbee.app.agent_configs.opencode import opencode_config
15from lilbee.cli.launchers import config_file
16from lilbee.cli.launchers.launcher import LILBEE_TOKEN_ENV_VAR, run_launcher
17from lilbee.cli.launchers.server import LOOPBACK, client_chat_ctx
18from lilbee.cli.launchers.setup_gate import confirm_first_run_setup
19from lilbee.cli.launchers.skill_install import install_bundled_skill
20from lilbee.core.config import cfg
22_OPENCODE_INSTALL_HINT = "opencode binary not found on PATH. Install it from https://opencode.ai/."
23_TOKEN_REF = "{env:" + LILBEE_TOKEN_ENV_VAR + "}"
24_SETUP_MARKER_NAME = "opencode-setup.json"
25_MCP_CONTAINER_KEY = "mcp"
28def _opencode_config_dir() -> Path:
29 """Return the opencode config directory for the current platform.
31 On Windows, opencode (Go) reads %APPDATA%\\opencode; on POSIX it reads
32 ~/.config/opencode. Using the wrong directory on Windows means every
33 ``lilbee launch opencode`` write is silently discarded.
34 """
35 if sys.platform == "win32":
36 appdata = os.environ.get("APPDATA", "")
37 base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
38 return base / "opencode"
39 return Path.home() / ".config" / "opencode"
42def _opencode_config_path() -> Path:
43 return _opencode_config_dir() / "opencode.json"
46def _opencode_skill_dest() -> Path:
47 return _opencode_config_dir() / "skills" / "lilbee-mcp"
50def _print_setup_plan() -> None:
51 """Tell the user exactly which files the first-run setup writes."""
52 typer.secho("First-time opencode setup will write:", fg=typer.colors.CYAN)
53 typer.echo(f" - lilbee provider + MCP entry -> {_opencode_config_path()}")
54 typer.echo(f" - lilbee MCP skill -> {_opencode_skill_dest()}")
55 typer.echo(
56 "Only the `lilbee` keys and the active model are written; your other "
57 "providers and settings are preserved. The token is referenced by env, "
58 "never written as a literal. To undo, remove the `lilbee` entries and the skill dir."
59 )
62def _confirm_setup(assume_yes: bool) -> bool:
63 """Prompt before the first opencode setup; True means proceed."""
64 return confirm_first_run_setup(
65 marker_name=_SETUP_MARKER_NAME,
66 client_name="opencode",
67 print_plan=_print_setup_plan,
68 assume_yes=assume_yes,
69 )
72class OpencodeLauncher:
73 """``Launcher`` implementation for opencode (https://opencode.ai/)."""
75 name = "opencode"
76 install_hint = _OPENCODE_INSTALL_HINT
78 def __init__(self, *, assume_yes: bool = False, include_mcp: bool = True) -> None:
79 self._assume_yes = assume_yes
80 self._include_mcp = include_mcp
82 def find_binary(self) -> str | None:
83 return shutil.which("opencode")
85 def prepare(
86 self, *, token: str, port: int, model_refs: list[str]
87 ) -> tuple[list[str], dict[str, str]]:
88 if not _confirm_setup(self._assume_yes):
89 raise typer.Exit(0)
90 # The token is referenced via {env:...}; opencode expands it at load, so the
91 # written config never holds the literal. The launcher sets it in the env.
92 block = opencode_config(
93 base_url=f"http://{LOOPBACK}:{port}",
94 api_key=_TOKEN_REF,
95 model_refs=model_refs,
96 chat_ctx=client_chat_ctx(port),
97 default_ref=str(cfg.chat_model),
98 include_mcp=self._include_mcp,
99 )
100 # Load (and validate) before any side effect, so a corrupt config aborts
101 # without writing or installing anything.
102 config = config_file.load_config_dict(
103 _opencode_config_path(),
104 parse=json.loads,
105 parse_error=json.JSONDecodeError,
106 label="opencode config (opencode.json)",
107 )
108 deep_merge(config, block)
109 if not self._include_mcp:
110 prune_lilbee(config, _MCP_CONTAINER_KEY)
111 config_file.atomic_write_text(_opencode_config_path(), json.dumps(config, indent=2))
112 # The lilbee-mcp guidance skill only helps when the MCP tool is wired in;
113 # skip it when MCP is disabled (a previously-installed skill is left alone).
114 if self._include_mcp:
115 install_bundled_skill(_opencode_skill_dest())
116 return ([], {**os.environ, LILBEE_TOKEN_ENV_VAR: token})
119def opencode_cmd(
120 yes: bool = typer.Option(
121 False,
122 "--no-prompt",
123 "--yes",
124 "-y",
125 help="Proceed with first-run setup without the interactive prompt (for scripts/CI).",
126 ),
127 mcp: bool | None = typer.Option(
128 None,
129 "--mcp/--no-mcp",
130 help="Inject lilbee's MCP search tool into opencode. Defaults to the "
131 "agent_mcp_enabled config; --mcp/--no-mcp overrides it for this launch.",
132 ),
133) -> None:
134 """Launch opencode with lilbee as its model provider."""
135 include_mcp = cfg.agent_mcp_enabled if mcp is None else mcp
136 run_launcher(OpencodeLauncher(assume_yes=yes, include_mcp=include_mcp))