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

1"""Opencode launcher: wires the inline config, installs the skill, runs opencode.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import os 

7import shutil 

8import sys 

9from pathlib import Path 

10 

11import typer 

12 

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 

21 

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" 

26 

27 

28def _opencode_config_dir() -> Path: 

29 """Return the opencode config directory for the current platform. 

30 

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" 

40 

41 

42def _opencode_config_path() -> Path: 

43 return _opencode_config_dir() / "opencode.json" 

44 

45 

46def _opencode_skill_dest() -> Path: 

47 return _opencode_config_dir() / "skills" / "lilbee-mcp" 

48 

49 

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 ) 

60 

61 

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 ) 

70 

71 

72class OpencodeLauncher: 

73 """``Launcher`` implementation for opencode (https://opencode.ai/).""" 

74 

75 name = "opencode" 

76 install_hint = _OPENCODE_INSTALL_HINT 

77 

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 

81 

82 def find_binary(self) -> str | None: 

83 return shutil.which("opencode") 

84 

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}) 

117 

118 

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