osFoundry처럼 Codex CLI를 Python으로 감싸는 방법
osFoundry 데스크톱 앱에서 Codex의 "로그인 확인"을 누르면 아래 명령이 실행된다. 명령을 조립하는 건 src-tauri/src/providers/agent_cli.rs의 build_codex_command이고, cli:codex:agent로 도는 실제 작업도 전부 이 함수를 거친다. codex exec "Reply with the single word: ok" \ --json \ --skip-git-repo-check \ --ignore-user-config \ --ignore-rules \ -s read-only \ -c 'approval_policy="never"' 하나씩 뜯어보자. exec 뒤의 프롬프트는 위치 인자 하나다. Codex에는 시스템 프롬프트를 넣는 플래그가 없다. 그래서 osFoundry는 system + "\n\n" + 대화 기록을 문자열 하나로 합쳐서 넘긴다. --json을 붙이면 stdout이 한 줄에 JSON 객체 하나씩 나온다. osFoundry가 읽는 출력은 이게 전부다. --skip-git-repo-check가 있으면 git 저장소가 아닌 폴더에서도 실행이 시작된다. --ignore-user-config는 ~/.codex/config.toml을 읽지 않게 한다. 내가 따로 붙여 둔 MCP 서버가 osFoundry 실행에 끼어들지 않는다. 인증은 CODEX_HOME에서 읽으니 로그인은 그대로 살아 있다. --ignore-rules는 codex exec --help에 이 플래그가 보일 때만 붙인다. 구버전 Codex는 모르는 플래그를 받으면 실행 자체가 죽는다. 그래서 버전 표를 따로 관리하지 않고 바이너리에 직접 물어본다. -s와 approval_policy="never"는 샌드박스 종류를 정하고, 승인해 줄 사람이 없다고 알려 준다. 자율 실행에는 -s workspace-write가 붙는다. osFoundry의 MCP 브리지를 붙이는 실행만 대신 --dangerously-bypass-approvals-and-sandbox가 붙는데, 이것도 git worktree 안에서만 그렇다. 프로세스를 띄우기 전에 osFoundry는 자식 프로세스 환경에서 CODEX_API_KEY, OPENAI_API_KEY, OPENAI_BASE_URL을 뺀다. 이 값들이 남아 있으면 실행이 ChatGPT 로그인이 아니라 종량제 API 계정으로 과금되기 때문이다. 이 글에서 다루는 건 이 파이프의 반대편이다. 스트림 형식이 약속되지 않은 에이전트 CLI에서 답과 진행 상황, 그리고 깔끔한 실패를 꺼내야 한다. 최종 답 파일 대신 왜 이벤트를 읽나 이런 CLI에서 답을 받는 방법은 두 가지다. 최종 답을 파일로 받는다. 출력 경로를 넘기고(codex exec -o FILE, osFoundry 모듈 주석에도 문서화된 파일 출력으로 적혀 있다), 프로세스가 끝나길 기다렸다가 파일을 읽는다. 계약은 "파일 하나에 문자열 하나"가 전부다. stdout 이벤트를 읽는다. --json을 붙여 한 줄씩 읽고, 아는 아이템만 골라낸다. 지금 osFoundry는 2번이다. 파서는 runtime/session_event.rs의 parse_codex_line에 있고, 보는 게 생각보다 적다. 이벤트 바뀌는 것 item.completed + agent_message 답 텍스트 item.completed + reasoning Thinking: … 줄 item.completed + command_execution Using shell — item.completed + mcp_tool_call Using … (Codex는 server와 tool을 따로 준다) item.completed + file_change / todo_list Edited / Plan: 1. … 2. … turn.completed 사용량 (input_tokens, cached_input_tokens, output_tokens) turn.failed, error 실패. 메시지가 인증 오류처럼 보이면 인증 플래그도 세운다 나머지 이벤트는 버리고, JSON이 아닌 줄도 건너뛴다. 스키마가 고정되지 않은 상대인데도 돌아가는 건 이 관대함 덕분이다. 모르는 이벤트 타입이 늘어도 손해가 없다. 깨질 수 있는 건 위 일곱 줄에 걸린 필드 이름이 바뀔 때뿐이다. 대신 단위가 굵다. Codex가 내보내는 건 토큰 델타가 아니라 완료된 아이템이다. 답은 메시지가 다 써진 순간에 한꺼번에 도착한다. 앱이 Codex에 "실시간 스트림 없음"을 붙이고 run-then-emit이라고 설명하는 이유다. 진행 줄은 아이템이 끝날 때마다 하나씩 온다. Mac에서 둘 다 다시 만들어 봤다 이 Mac에는 Codex 바이너리가 없다. 그래서 fake_codex.py라는 대역을 만들었다. 같은 플래그를 받고, 위의 이벤트 타입을 정해진 지연으로 찍고, 종료할 때 -o 파일에 최종 메시지를 쓴다. 멈추기, 크래시, 인증 실패도 시킬 수 있다. Codex가 아니니까 잴 수 있는 건 래퍼 쪽 동작이지 Codex의 속도가 아니다. 대역의 타임라인은 이렇다. 0.4초에 reasoning이 나온다. 이어서 셸 자식 프로세스(sleep 0.8)를 띄운다. 그로부터 약 1.3초 뒤에 agent_message, 다시 0.2초 뒤에 turn.completed. 답 파일은 종료 시점에 쓴다. 래퍼 wrap.py는 Python 3.9.6 표준 라이브러리만 썼다. 이벤트 방식의 핵심만 추리면 이렇다. def run_events(argv, timeout, env=None, group=True, drain_stderr=True): t0 = time.monotonic() p = subprocess.Popen(argv + ["--json"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, start_new_session=group) err = [] if drain_stderr: threading.Thread(target=_drain, args=(p.stderr, err), daemon=True).start() lines = queue.Queue() threading.Thread(target=pump, args=(p.stdout, lines), daemon=True).start() deadline = t0 + timeout while True: left = deadline - time.monotonic() if left
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to