Skip to content

Commit 4e3404a

Browse files
authored
Add support for OpenCode on the prompt command (#1421)
1 parent ac2f0f3 commit 4e3404a

File tree

3 files changed

+257
-28
lines changed

3 files changed

+257
-28
lines changed

logfire/_internal/cli/__init__.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -425,10 +425,11 @@ def _main(args: list[str] | None = None) -> None:
425425
cmd_read_tokens_create.set_defaults(func=parse_create_read_token)
426426

427427
cmd_prompt = subparsers.add_parser('prompt', help=parse_prompt.__doc__)
428-
agent_code_group = cmd_prompt.add_argument_group(title='code agentic specific options')
429-
claude_or_codex_group = agent_code_group.add_mutually_exclusive_group()
430-
claude_or_codex_group.add_argument('--claude', action='store_true', help='verify the Claude Code setup')
431-
claude_or_codex_group.add_argument('--codex', action='store_true', help='verify the Cursor setup')
428+
agent_code_argument_group = cmd_prompt.add_argument_group(title='code agentic specific options')
429+
agent_code_group = agent_code_argument_group.add_mutually_exclusive_group()
430+
agent_code_group.add_argument('--claude', action='store_true', help='verify the Claude Code setup')
431+
agent_code_group.add_argument('--codex', action='store_true', help='verify the Cursor setup')
432+
agent_code_group.add_argument('--opencode', action='store_true', help='verify the OpenCode setup')
432433
cmd_prompt.add_argument('--project', action=OrgProjectAction, help='project in the format <org>/<project>')
433434
cmd_prompt.add_argument('issue', nargs='?', help='the issue to get a prompt for')
434435
cmd_prompt.set_defaults(func=parse_prompt)

logfire/_internal/cli/prompt.py

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
from __future__ import annotations
44

55
import argparse
6+
import json
67
import os
78
import shlex
9+
import shutil
810
import subprocess
911
import sys
1012
from pathlib import Path
13+
from typing import Any
1114

1215
from rich.console import Console
1316

@@ -36,27 +39,11 @@ def parse_prompt(args: argparse.Namespace) -> None:
3639
return
3740

3841
if args.claude:
39-
output = subprocess.check_output(['claude', 'mcp', 'list'])
40-
if 'logfire-mcp' not in output.decode('utf-8'):
41-
token = _create_read_token(client, args.organization, args.project, console)
42-
subprocess.check_output(
43-
shlex.split(f'claude mcp add logfire -e LOGFIRE_READ_TOKEN={token} -- uvx logfire-mcp@latest')
44-
)
45-
console.print('Logfire MCP server added to Claude.', style='green')
42+
configure_claude(client, args.organization, args.project, console)
4643
elif args.codex:
47-
codex_home = Path(os.getenv('CODEX_HOME', Path.home() / '.codex'))
48-
codex_config = codex_home / 'config.toml'
49-
if not codex_config.exists():
50-
console.print('Codex config file not found. Install `codex`, or remove the `--codex` flag.')
51-
return
52-
53-
codex_config_content = codex_config.read_text()
54-
55-
if 'logfire-mcp' not in codex_config_content:
56-
token = _create_read_token(client, args.organization, args.project, console)
57-
mcp_server_toml = LOGFIRE_MCP_TOML.format(token=token)
58-
codex_config.write_text(codex_config_content + mcp_server_toml)
59-
console.print('Logfire MCP server added to Codex.', style='green')
44+
configure_codex(client, args.organization, args.project, console)
45+
elif args.opencode:
46+
configure_opencode(client, args.organization, args.project, console)
6047

6148
response = client.get_prompt(args.organization, args.project, args.issue)
6249
sys.stdout.write(response['prompt'])
@@ -66,3 +53,75 @@ def _create_read_token(client: LogfireClient, organization: str, project: str, c
6653
console.print('Logfire MCP server not found. Creating a read token...', style='yellow')
6754
response = client.create_read_token(organization, project)
6855
return response['token']
56+
57+
58+
def configure_claude(client: LogfireClient, organization: str, project: str, console: Console) -> None:
59+
if not shutil.which('claude'):
60+
console.print('claude is not installed. Install `claude`, or remove the `--claude` flag.')
61+
exit(1)
62+
63+
output = subprocess.check_output(['claude', 'mcp', 'list'])
64+
if 'logfire-mcp' not in output.decode('utf-8'):
65+
token = _create_read_token(client, organization, project, console)
66+
subprocess.check_output(
67+
shlex.split(f'claude mcp add logfire -e LOGFIRE_READ_TOKEN={token} -- uvx logfire-mcp@latest')
68+
)
69+
console.print('Logfire MCP server added to Claude.', style='green')
70+
71+
72+
def configure_codex(client: LogfireClient, organization: str, project: str, console: Console) -> None:
73+
if not shutil.which('codex'):
74+
console.print('codex is not installed. Install `codex`, or remove the `--codex` flag.')
75+
exit(1)
76+
77+
codex_home = Path(os.getenv('CODEX_HOME', Path.home() / '.codex'))
78+
codex_config = codex_home / 'config.toml'
79+
if not codex_config.exists():
80+
console.print('Codex config file not found. Install `codex`, or remove the `--codex` flag.')
81+
exit(1)
82+
83+
codex_config_content = codex_config.read_text()
84+
85+
if 'logfire-mcp' not in codex_config_content:
86+
token = _create_read_token(client, organization, project, console)
87+
mcp_server_toml = LOGFIRE_MCP_TOML.format(token=token)
88+
codex_config.write_text(codex_config_content + mcp_server_toml)
89+
console.print('Logfire MCP server added to Codex.', style='green')
90+
91+
92+
def configure_opencode(client: LogfireClient, organization: str, project: str, console: Console) -> None:
93+
# Check if opencode is installed
94+
if not shutil.which('opencode'):
95+
console.print('opencode is not installed. Install `opencode`, or remove the `--opencode` flag.')
96+
exit(1)
97+
98+
try:
99+
output = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'])
100+
except subprocess.CalledProcessError:
101+
root_dir = Path.cwd()
102+
else:
103+
root_dir = Path(output.decode('utf-8').strip())
104+
105+
opencode_config = root_dir / 'opencode.jsonc'
106+
opencode_config.touch()
107+
108+
opencode_config_content = opencode_config.read_text()
109+
110+
if 'logfire-mcp' not in opencode_config_content:
111+
token = _create_read_token(client, organization, project, console)
112+
if not opencode_config_content:
113+
opencode_config.write_text(json.dumps(opencode_mcp_json(token), indent=2))
114+
else:
115+
opencode_config_json = json.loads(opencode_config_content)
116+
opencode_config_json.setdefault('mcp', {})
117+
opencode_config_json['mcp'] = {'logfire-mcp': opencode_mcp_json(token)}
118+
opencode_config.write_text(json.dumps(opencode_config_json, indent=2))
119+
console.print('Logfire MCP server added to OpenCode.', style='green')
120+
121+
122+
def logfire_mcp_json(token: str) -> dict[str, Any]:
123+
return {'command': 'uvx', 'args': ['logfire-mcp@latest'], 'env': {'LOGFIRE_READ_TOKEN': token}}
124+
125+
126+
def opencode_mcp_json(token: str) -> dict[str, Any]:
127+
return {'mcp': {'logfire-mcp': logfire_mcp_json(token)}}

tests/test_cli.py

Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import re
88
import shlex
9+
import shutil
910
import subprocess
1011
import sys
1112
import types
@@ -1748,7 +1749,11 @@ def test_parse_prompt(prompt_http_calls: None, capsys: pytest.CaptureFixture[str
17481749
assert capsys.readouterr().out == snapshot('This is the prompt\n')
17491750

17501751

1751-
def test_parse_prompt_codex(prompt_http_calls: None, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
1752+
def test_parse_prompt_codex(
1753+
prompt_http_calls: None, capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1754+
) -> None:
1755+
monkeypatch.setattr(shutil, 'which', lambda x: True) # type: ignore
1756+
17521757
codex_path = tmp_path / 'codex'
17531758
codex_path.mkdir()
17541759
codex_config_path = codex_path / 'config.toml'
@@ -1772,13 +1777,28 @@ def test_parse_prompt_codex(prompt_http_calls: None, capsys: pytest.CaptureFixtu
17721777
""")
17731778

17741779

1780+
def test_parse_prompt_codex_not_installed(
1781+
prompt_http_calls: None, capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1782+
) -> None:
1783+
monkeypatch.setattr(shutil, 'which', lambda x: False) # type: ignore
1784+
1785+
with pytest.raises(SystemExit):
1786+
main(['prompt', '--project', 'fake_org/myproject', 'fix-span-issue:123', '--codex'])
1787+
1788+
assert capsys.readouterr().err == snapshot("""\
1789+
codex is not installed. Install `codex`, or remove the `--codex` flag.
1790+
""")
1791+
1792+
17751793
def test_parse_prompt_codex_config_not_found(
1776-
prompt_http_calls: None, capsys: pytest.CaptureFixture[str], tmp_path: Path
1794+
prompt_http_calls: None, capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch
17771795
) -> None:
1796+
monkeypatch.setattr(shutil, 'which', lambda x: True) # type: ignore
1797+
17781798
codex_path = tmp_path / 'codex'
17791799
codex_path.mkdir()
17801800

1781-
with patch.dict(os.environ, {'CODEX_HOME': str(codex_path)}):
1801+
with patch.dict(os.environ, {'CODEX_HOME': str(codex_path)}), pytest.raises(SystemExit):
17821802
main(['prompt', '--project', 'fake_org/myproject', 'fix-span-issue:123', '--codex'])
17831803

17841804
assert capsys.readouterr().err == snapshot(
@@ -1787,8 +1807,10 @@ def test_parse_prompt_codex_config_not_found(
17871807

17881808

17891809
def test_parse_prompt_codex_logfire_mcp_installed(
1790-
prompt_http_calls: None, capsys: pytest.CaptureFixture[str], tmp_path: Path
1810+
prompt_http_calls: None, capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch
17911811
) -> None:
1812+
monkeypatch.setattr(shutil, 'which', lambda x: True) # type: ignore
1813+
17921814
codex_path = tmp_path / 'codex'
17931815
codex_path.mkdir()
17941816
codex_config_path = codex_path / 'config.toml'
@@ -1803,6 +1825,8 @@ def test_parse_prompt_codex_logfire_mcp_installed(
18031825
def test_parse_prompt_claude(
18041826
prompt_http_calls: None, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
18051827
) -> None:
1828+
monkeypatch.setattr(shutil, 'which', lambda x: True) # type: ignore
1829+
18061830
def logfire_mcp_installed(_: list[str]) -> bytes:
18071831
return b'logfire-mcp is installed'
18081832

@@ -1812,9 +1836,24 @@ def logfire_mcp_installed(_: list[str]) -> bytes:
18121836
assert capsys.readouterr().out == snapshot('This is the prompt\n')
18131837

18141838

1839+
def test_parse_prompt_claude_not_installed(
1840+
prompt_http_calls: None, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
1841+
) -> None:
1842+
monkeypatch.setattr(shutil, 'which', lambda x: False) # type: ignore
1843+
1844+
with pytest.raises(SystemExit):
1845+
main(['prompt', '--project', 'fake_org/myproject', 'fix-span-issue:123', '--claude'])
1846+
1847+
assert capsys.readouterr().err == snapshot("""\
1848+
claude is not installed. Install `claude`, or remove the `--claude` flag.
1849+
""")
1850+
1851+
18151852
def test_parse_prompt_claude_no_mcp(
18161853
prompt_http_calls: None, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
18171854
) -> None:
1855+
monkeypatch.setattr(shutil, 'which', lambda x: True) # type: ignore
1856+
18181857
def logfire_mcp_installed(_: list[str]) -> bytes:
18191858
return b'not installed'
18201859

@@ -1829,6 +1868,136 @@ def logfire_mcp_installed(_: list[str]) -> bytes:
18291868
""")
18301869

18311870

1871+
def test_parse_prompt_opencode(
1872+
prompt_http_calls: None,
1873+
capsys: pytest.CaptureFixture[str],
1874+
tmp_path: Path,
1875+
monkeypatch: pytest.MonkeyPatch,
1876+
) -> None:
1877+
monkeypatch.setattr(shutil, 'which', lambda x: True) # type: ignore
1878+
monkeypatch.setattr(Path, 'cwd', lambda: tmp_path)
1879+
1880+
def check_output(x: list[str]) -> bytes:
1881+
return tmp_path.as_posix().encode('utf-8')
1882+
1883+
monkeypatch.setattr(subprocess, 'check_output', check_output)
1884+
1885+
main(['prompt', '--project', 'fake_org/myproject', 'fix-span-issue:123', '--opencode'])
1886+
1887+
out, err = capsys.readouterr()
1888+
assert out == snapshot("""\
1889+
This is the prompt
1890+
""")
1891+
assert err == snapshot("""\
1892+
Logfire MCP server not found. Creating a read token...
1893+
Logfire MCP server added to OpenCode.
1894+
""")
1895+
1896+
1897+
def test_parse_prompt_opencode_no_git(
1898+
prompt_http_calls: None,
1899+
capsys: pytest.CaptureFixture[str],
1900+
tmp_path: Path,
1901+
monkeypatch: pytest.MonkeyPatch,
1902+
) -> None:
1903+
monkeypatch.setattr(shutil, 'which', lambda x: True) # type: ignore
1904+
monkeypatch.setattr(Path, 'cwd', lambda: tmp_path)
1905+
1906+
def check_output(x: list[str]) -> bytes:
1907+
raise subprocess.CalledProcessError(1, x)
1908+
1909+
monkeypatch.setattr(subprocess, 'check_output', check_output)
1910+
1911+
main(['prompt', '--project', 'fake_org/myproject', 'fix-span-issue:123', '--opencode'])
1912+
1913+
out, err = capsys.readouterr()
1914+
assert out == snapshot("""\
1915+
This is the prompt
1916+
""")
1917+
assert err == snapshot("""\
1918+
Logfire MCP server not found. Creating a read token...
1919+
Logfire MCP server added to OpenCode.
1920+
""")
1921+
1922+
1923+
def test_parse_prompt_opencode_not_installed(
1924+
prompt_http_calls: None,
1925+
capsys: pytest.CaptureFixture[str],
1926+
tmp_path: Path,
1927+
monkeypatch: pytest.MonkeyPatch,
1928+
) -> None:
1929+
monkeypatch.setattr(shutil, 'which', lambda x: False) # type: ignore
1930+
monkeypatch.setattr(Path, 'cwd', lambda: tmp_path)
1931+
1932+
with pytest.raises(SystemExit):
1933+
main(['prompt', '--project', 'fake_org/myproject', 'fix-span-issue:123', '--opencode'])
1934+
1935+
out, err = capsys.readouterr()
1936+
assert out == snapshot('')
1937+
assert err == snapshot("""\
1938+
opencode is not installed. Install `opencode`, or remove the `--opencode` flag.
1939+
""")
1940+
1941+
1942+
def test_parse_prompt_opencode_logfire_mcp_installed(
1943+
prompt_http_calls: None,
1944+
capsys: pytest.CaptureFixture[str],
1945+
tmp_path: Path,
1946+
monkeypatch: pytest.MonkeyPatch,
1947+
) -> None:
1948+
monkeypatch.setattr(shutil, 'which', lambda x: True) # type: ignore
1949+
monkeypatch.setattr(Path, 'cwd', lambda: tmp_path)
1950+
1951+
(tmp_path / 'opencode.jsonc').write_text("""
1952+
{
1953+
"mcp": {
1954+
"logfire-mcp": {
1955+
"command": "uvx",
1956+
"args": ["logfire-mcp@latest"],
1957+
"env": {"LOGFIRE_READ_TOKEN": "fake_token"}
1958+
}
1959+
}
1960+
}
1961+
""")
1962+
1963+
def check_output(x: list[str]) -> bytes:
1964+
return tmp_path.as_posix().encode('utf-8')
1965+
1966+
monkeypatch.setattr(subprocess, 'check_output', check_output)
1967+
1968+
main(['prompt', '--project', 'fake_org/myproject', 'fix-span-issue:123', '--opencode'])
1969+
1970+
out, err = capsys.readouterr()
1971+
assert out == snapshot('This is the prompt\n')
1972+
assert err == snapshot('')
1973+
1974+
1975+
def test_parse_opencode_logfire_mcp_not_installed_with_existing_config(
1976+
prompt_http_calls: None,
1977+
capsys: pytest.CaptureFixture[str],
1978+
tmp_path: Path,
1979+
monkeypatch: pytest.MonkeyPatch,
1980+
) -> None:
1981+
monkeypatch.setattr(shutil, 'which', lambda x: True) # type: ignore
1982+
monkeypatch.setattr(Path, 'cwd', lambda: tmp_path)
1983+
1984+
(tmp_path / 'opencode.jsonc').write_text('{}')
1985+
1986+
def check_output(x: list[str]) -> bytes:
1987+
return tmp_path.as_posix().encode('utf-8')
1988+
1989+
monkeypatch.setattr(subprocess, 'check_output', check_output)
1990+
1991+
main(['prompt', '--project', 'fake_org/myproject', 'fix-span-issue:123', '--opencode'])
1992+
1993+
out, err = capsys.readouterr()
1994+
assert out == snapshot('This is the prompt\n')
1995+
assert err == snapshot("""\
1996+
Logfire MCP server not found. Creating a read token...
1997+
Logfire MCP server added to OpenCode.
1998+
""")
1999+
2000+
18322001
def test_base_url_and_logfire_url(
18332002
tmp_dir_cwd: Path, logfire_credentials: LogfireCredentials, capsys: pytest.CaptureFixture[str]
18342003
):

0 commit comments

Comments
 (0)