Skip to content

Commit 844b550

Browse files
authored
Merge pull request #190 from ks6088ts-labs/feature/issue-189_codex-cli
add CLI runner app
2 parents 893ebbb + 84f7b68 commit 844b550

File tree

2 files changed

+246
-0
lines changed

2 files changed

+246
-0
lines changed

docs/references.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,35 @@
114114
### [Langfuse](https://langfuse.com/)
115115

116116
- [Cookbook: LangGraph Integration](https://langfuse.com/guides/cookbook/integration_langgraph)
117+
118+
### [Codex CLI](https://github.com/openai/codex)
119+
120+
- Azure OpenAI で Codex CLI を使う: [Codex Azure OpenAI Integration: Fast & Secure Code Development](https://devblogs.microsoft.com/all-things-azure/codex-azure-openai-integration-fast-secure-code-development/)
121+
- [OpenAI Codex CLI のクイックスタート](https://note.com/npaka/n/n7b6448020250)
122+
123+
```shell
124+
# Install Codex CLI
125+
npm install -g @openai/codex
126+
127+
# Generate shell completion scripts
128+
codex completion zsh
129+
130+
# Dump configurations
131+
cat ~/.codex/config.toml
132+
133+
# Set up environment variables
134+
export AZURE_OPENAI_API_KEY="<your-api-key>"
135+
136+
# MCP server management: https://qiita.com/tomada/items/2eb8d5b5173a4d70b287
137+
## Add a global MCP server entry
138+
codex mcp add context7 -- npx -y @upstash/context7-mcp
139+
codex mcp add playwright -- npx -y @playwright/mcp@latest
140+
codex mcp add mslearn -- npx -y mcp-remote "https://learn.microsoft.com/api/mcp" # ref. https://zenn.dev/yanskun/articles/codex-remote-mcp
141+
## Remove MCP server
142+
codex mcp remove context7
143+
## List MCP servers
144+
codex mcp list
145+
146+
# Run Codex non-interactively
147+
codex exec "Playwright MCP を使って Yahoo リアルタイム検索の上位キーワードをまとめて"
148+
```
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""Simple Streamlit runner for executing shell commands and tailing their output."""
2+
3+
from __future__ import annotations
4+
5+
import html
6+
import subprocess
7+
import threading
8+
import time
9+
from datetime import datetime
10+
from queue import Empty, Queue
11+
12+
import streamlit as st
13+
14+
LogEntry = tuple[str, str]
15+
16+
17+
LOG_COLORS = {
18+
"stdout": "#e8f5e9",
19+
"stderr": "#ff7961",
20+
}
21+
LOG_FONT_FAMILY = "SFMono-Regular,Consolas,Menlo,monospace"
22+
MAX_LOG_LINES = 4000
23+
24+
25+
def _init_state() -> None:
26+
if "cli_runner" not in st.session_state:
27+
st.session_state.cli_runner = {
28+
"command": "",
29+
"process": None,
30+
"queue": Queue(),
31+
"logs": [],
32+
"start_time": None,
33+
"returncode": None,
34+
"auto_refresh": True,
35+
}
36+
37+
38+
def _stream_reader(stream, label: str, buffer: Queue) -> None:
39+
for raw_line in iter(stream.readline, ""):
40+
buffer.put((label, raw_line.rstrip("\n")))
41+
stream.close()
42+
43+
44+
def _start_process(command: str) -> None:
45+
runner = st.session_state.cli_runner
46+
if runner.get("process") and runner["process"].poll() is None:
47+
st.sidebar.warning("他のプロセスが実行中です。停止してから再度実行してください。")
48+
return
49+
50+
try:
51+
process = subprocess.Popen(
52+
command,
53+
shell=True,
54+
stdout=subprocess.PIPE,
55+
stderr=subprocess.PIPE,
56+
text=True,
57+
bufsize=1,
58+
universal_newlines=True,
59+
)
60+
except OSError as exc:
61+
st.sidebar.error(f"プロセスを起動できませんでした: {exc}")
62+
return
63+
64+
runner.update(
65+
{
66+
"command": command,
67+
"process": process,
68+
"queue": Queue(),
69+
"logs": [],
70+
"start_time": datetime.now(),
71+
"returncode": None,
72+
}
73+
)
74+
75+
if process.stdout:
76+
stdout_thread = threading.Thread(
77+
target=_stream_reader,
78+
args=(process.stdout, "stdout", runner["queue"]),
79+
daemon=True,
80+
)
81+
stdout_thread.start()
82+
83+
if process.stderr:
84+
stderr_thread = threading.Thread(
85+
target=_stream_reader,
86+
args=(process.stderr, "stderr", runner["queue"]),
87+
daemon=True,
88+
)
89+
stderr_thread.start()
90+
91+
92+
def _drain_queue() -> None:
93+
runner = st.session_state.cli_runner
94+
queue: Queue | None = runner.get("queue")
95+
if queue is None:
96+
return
97+
logs: list[LogEntry] = runner.get("logs", [])
98+
99+
while True:
100+
try:
101+
entry = queue.get_nowait()
102+
except Empty:
103+
break
104+
logs.append(entry)
105+
if len(logs) > MAX_LOG_LINES:
106+
logs.pop(0)
107+
108+
runner["logs"] = logs
109+
110+
111+
def _terminate_process() -> None:
112+
runner = st.session_state.cli_runner
113+
process = runner.get("process")
114+
if not process or process.poll() is not None:
115+
return
116+
117+
process.terminate()
118+
try:
119+
process.wait(timeout=5)
120+
except subprocess.TimeoutExpired:
121+
process.kill()
122+
runner["returncode"] = process.poll()
123+
124+
125+
def _render_logs(logs: list[LogEntry]) -> None:
126+
if not logs:
127+
st.info("まだ出力はありません。コマンドを実行してください。")
128+
return
129+
130+
log_container = st.container()
131+
lines = []
132+
for stream_label, line in logs:
133+
color = LOG_COLORS.get(stream_label, "#e0e0e0")
134+
safe_line = html.escape(line)
135+
lines.append(
136+
f"<div style='color:{color};font-family:{LOG_FONT_FAMILY};white-space:pre-wrap;margin:0;'>{safe_line}</div>"
137+
)
138+
log_container.markdown("\n".join(lines), unsafe_allow_html=True)
139+
140+
141+
_init_state()
142+
runner = st.session_state.cli_runner
143+
process = runner.get("process")
144+
is_running = bool(process) and process.poll() is None
145+
146+
st.title("CLI Runner")
147+
148+
with st.sidebar:
149+
st.header("Command Settings")
150+
st.text("CLIコマンドを指定して実行します。")
151+
st.session_state.cli_runner["command"] = st.text_input(
152+
label="Command",
153+
key="cli_runner_command_input",
154+
value="codex exec --help",
155+
placeholder="e.g. ls -la; while true; do date +%s; sleep 1; done",
156+
)
157+
158+
run_clicked = st.button("Run", use_container_width=True)
159+
stop_clicked = st.button(
160+
"Stop",
161+
use_container_width=True,
162+
disabled=not is_running,
163+
)
164+
165+
runner["auto_refresh"] = st.checkbox(
166+
label="Auto refresh (1s)",
167+
value=runner.get("auto_refresh", True),
168+
)
169+
170+
if st.button("Clear Logs", use_container_width=True):
171+
runner["logs"] = []
172+
173+
if run_clicked:
174+
command = st.session_state.cli_runner.get("command", "").strip()
175+
if not command:
176+
st.sidebar.error("コマンドを入力してください。")
177+
else:
178+
_start_process(command)
179+
180+
if stop_clicked:
181+
_terminate_process()
182+
183+
_drain_queue()
184+
185+
process = runner.get("process")
186+
is_running = bool(process) and process.poll() is None
187+
188+
if process and not is_running and runner.get("returncode") is None:
189+
runner["returncode"] = process.poll()
190+
191+
status_placeholder = st.empty()
192+
if is_running:
193+
status_placeholder.info("プロセス実行中です…")
194+
else:
195+
return_code = runner.get("returncode")
196+
if return_code is None:
197+
status_placeholder.info("プロセスを待機しています。")
198+
elif return_code == 0:
199+
status_placeholder.success("プロセスが正常終了しました。")
200+
else:
201+
status_placeholder.error(f"プロセスが終了コード {return_code} で終了しました。")
202+
203+
if runner.get("start_time"):
204+
if is_running:
205+
elapsed = datetime.now() - runner["start_time"]
206+
st.caption(f"Started at {runner['start_time'].strftime('%Y-%m-%d %H:%M:%S')} | Elapsed: {elapsed}")
207+
else:
208+
st.caption(f"Started at {runner['start_time'].strftime('%Y-%m-%d %H:%M:%S')}")
209+
210+
_render_logs(runner.get("logs", []))
211+
212+
if is_running and runner.get("auto_refresh", True):
213+
time.sleep(1)
214+
st.rerun()

0 commit comments

Comments
 (0)