Skip to content

Commit 6d82719

Browse files
committed
chore: example cli
1 parent 2b2bddd commit 6d82719

5 files changed

Lines changed: 280 additions & 24 deletions

File tree

.github/workflows/release-published.yml

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
runs-on: ubuntu-latest
1919
outputs:
2020
tag: ${{ steps.ctx.outputs.tag }}
21-
version: ${{ steps.bump.outputs.version }}
21+
version: ${{ steps.resolve.outputs.version }}
2222
sha: ${{ steps.ctx.outputs.sha }}
2323
steps:
2424
- name: Checkout
@@ -42,42 +42,46 @@ jobs:
4242
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
4343
echo "Tag=$TAG TagSHA=$SHA"
4444
45-
- name: Compute next version (Conventional Commits)
46-
id: bump
45+
- name: Resolve version (tag or next)
46+
id: resolve
4747
shell: bash
4848
run: |
4949
set -euo pipefail
50+
TAG="${{ steps.ctx.outputs.tag }}"
51+
if [ "$TAG" != "next" ]; then
52+
# Use the tag's version directly (strip optional leading v)
53+
VER="${TAG#v}"
54+
if [[ ! "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
55+
echo "Release tag '$TAG' is not a valid semver (x.y.z or vX.Y.Z)." >&2
56+
exit 1
57+
fi
58+
echo "mode=explicit" >> "$GITHUB_OUTPUT"
59+
echo "version=$VER" >> "$GITHUB_OUTPUT"
60+
echo "Using explicit release version: $VER"
61+
exit 0
62+
fi
63+
# Compute next version from conventional commits when tag == 'next'
5064
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
5165
git fetch origin "$DEFAULT_BRANCH" --depth=1000
52-
# Find last semver tag
5366
LAST_TAG=$(git describe --tags --match 'v[0-9]*' --abbrev=0 2>/dev/null || true)
54-
RANGE=""
5567
if [ -n "$LAST_TAG" ]; then
5668
RANGE="$LAST_TAG..origin/$DEFAULT_BRANCH"
5769
else
5870
RANGE="origin/$DEFAULT_BRANCH"
5971
fi
6072
log=$(git log --pretty=%B $RANGE)
6173
bump="patch"
62-
if echo "$log" | grep -E '(^|\n)(feat|feat\(.+\))(!)?:' -q; then
63-
bump="minor"
64-
fi
65-
if echo "$log" | grep -E 'BREAKING CHANGE|(^|\n)([^\n!]+)!:' -q; then
66-
bump="major"
67-
fi
68-
# Derive current version
69-
if [ -n "$LAST_TAG" ]; then
70-
cur=${LAST_TAG#v}
71-
else
72-
cur="0.0.0"
73-
fi
74+
if echo "$log" | grep -E '(^|\n)(feat|feat\(.+\))(!)?:' -q; then bump="minor"; fi
75+
if echo "$log" | grep -E 'BREAKING CHANGE|(^|\n)([^\n!]+)!:' -q; then bump="major"; fi
76+
if [ -n "$LAST_TAG" ]; then cur=${LAST_TAG#v}; else cur="0.0.0"; fi
7477
IFS='.' read -r MA MI PA <<< "$cur"
7578
case "$bump" in
7679
major) MA=$((MA+1)); MI=0; PA=0 ;;
7780
minor) MI=$((MI+1)); PA=0 ;;
7881
patch) PA=$((PA+1)) ;;
7982
esac
8083
NEXT="$MA.$MI.$PA"
84+
echo "mode=bump" >> "$GITHUB_OUTPUT"
8185
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
8286
echo "Computed bump: $bump from $cur -> $NEXT"
8387
@@ -90,7 +94,7 @@ jobs:
9094
shell: bash
9195
run: |
9296
set -euo pipefail
93-
VER="${{ steps.bump.outputs.version }}"
97+
VER="${{ steps.resolve.outputs.version }}"
9498
# Update Cargo.toml version
9599
sed -i.bak -E "s/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/version = \"${VER}\"/" crates/codex_native/Cargo.toml
96100
rm -f crates/codex_native/Cargo.toml.bak
@@ -101,7 +105,7 @@ jobs:
101105
- name: Commit version bump
102106
shell: bash
103107
run: |
104-
VER="${{ steps.ctx.outputs.version }}"
108+
VER="${{ steps.resolve.outputs.version }}"
105109
if git diff --quiet; then
106110
echo "No changes to commit (versions already ${VER})."
107111
else
@@ -112,10 +116,11 @@ jobs:
112116
fi
113117
114118
- name: Create new semver tag and repoint release
119+
if: ${{ steps.resolve.outputs.mode == 'bump' }}
115120
shell: bash
116121
env:
117122
PLACEHOLDER_TAG: ${{ steps.ctx.outputs.tag }}
118-
VERSION: ${{ steps.bump.outputs.version }}
123+
VERSION: ${{ steps.resolve.outputs.version }}
119124
run: |
120125
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
121126
git fetch origin "$DEFAULT_BRANCH" --depth=1
@@ -130,6 +135,7 @@ jobs:
130135
echo "real_tag=$REAL_TAG" >> "$GITHUB_ENV"
131136
132137
- name: Update GitHub Release to new tag
138+
if: ${{ steps.resolve.outputs.mode == 'bump' }}
133139
uses: actions/github-script@v7
134140
with:
135141
script: |

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ repos:
66
- id: end-of-file-fixer
77
- id: check-yaml
88
- repo: https://github.com/astral-sh/ruff-pre-commit
9-
rev: v0.5.6
9+
rev: v0.13.1
1010
hooks:
1111
- id: ruff
1212
args: ["--fix"]
1313
- id: ruff-format
1414
- repo: https://github.com/pre-commit/mirrors-mypy
15-
rev: v1.10.0
15+
rev: v1.18.2
1616
hooks:
1717
- id: mypy
1818
additional_dependencies: []

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ cfg = CodexConfig(
3939
model_provider="openai",
4040
approval_policy=ApprovalPolicy.ON_REQUEST,
4141
sandbox_mode=SandboxMode.WORKSPACE_WRITE,
42+
include_apply_patch_tool=False, # disable apply patch tool by default
4243
)
4344

4445
# One‑shot
@@ -60,6 +61,19 @@ for ev in conv:
6061
Notes
6162
- `Event.msg` is a typed union (`EventMsg`). For raw dicts from the native layer, use `codex.native.start_exec_stream`.
6263

64+
### Example: basic_conversation.py
65+
66+
Run the interactive example that streams events and prompts for approvals:
67+
68+
```
69+
python examples/basic_conversation.py "ask me a question"
70+
```
71+
72+
Flags you may find useful:
73+
- `--approval on-request` (default) to be asked before running tools
74+
- `--sandbox workspace-write` to allow writes within the repo
75+
- `--allow-apply-patch` to include the apply‑patch tool in the session
76+
6377
## 3) API Overview
6478

6579
- `codex.run_exec(prompt, *, config=None, load_default_config=True, output_schema=None) -> list[Event]`

examples/basic_conversation.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
#!/usr/bin/env python3
2+
"""Minimal example app using CodexClient.
3+
4+
Usage:
5+
python examples/basic_conversation.py "Add a smoke test"
6+
7+
Flags:
8+
--model MODEL Model slug (default: gpt-5)
9+
--sandbox {read-only,workspace-write,danger-full-access}
10+
--approval {untrusted,on-failure,on-request,never}
11+
--auto-approve Auto‑approve exec/patch requests
12+
--exit-on-complete Exit after the first completed turn (default: prompt for another turn)
13+
--allow-apply-patch Enable the apply patch tool (disabled by default)
14+
15+
This script starts a stateful conversation, submits one user turn, then streams
16+
events to stdout. For approval requests, it either auto‑approves (when the flag
17+
is set) or prompts interactively.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import argparse
23+
import sys
24+
from typing import Any, cast
25+
26+
from codex import CodexClient, CodexConfig
27+
from codex.config import ApprovalPolicy, SandboxMode
28+
from codex.protocol.types import ReviewDecision
29+
30+
31+
def _etype(ev: Any) -> str:
32+
msg = getattr(ev, "msg", None)
33+
root = getattr(msg, "root", None)
34+
if root is not None and hasattr(root, "type"):
35+
val = root.type
36+
return cast(str, val) if isinstance(val, str) else "unknown"
37+
if isinstance(msg, dict):
38+
return cast("str", msg.get("type", "unknown"))
39+
return getattr(msg, "type", "unknown") or "unknown"
40+
41+
42+
def parse_args(argv: list[str]) -> argparse.Namespace:
43+
p = argparse.ArgumentParser(description="CodexClient example")
44+
p.add_argument("prompt", help="User prompt to send as a turn")
45+
p.add_argument("--model", default="gpt-5")
46+
p.add_argument(
47+
"--sandbox",
48+
choices=["read-only", "workspace-write", "danger-full-access"],
49+
default="workspace-write",
50+
)
51+
p.add_argument(
52+
"--approval",
53+
choices=["untrusted", "on-failure", "on-request", "never"],
54+
default="on-request",
55+
)
56+
p.add_argument("--auto-approve", action="store_true")
57+
p.add_argument("--exit-on-complete", action="store_true")
58+
p.add_argument("--allow-apply-patch", action="store_true")
59+
return p.parse_args(argv)
60+
61+
62+
def main(argv: list[str]) -> int:
63+
args = parse_args(argv)
64+
65+
# Map to enums for clarity (strings would also work)
66+
sandbox_map = {
67+
"read-only": SandboxMode.READ_ONLY,
68+
"workspace-write": SandboxMode.WORKSPACE_WRITE,
69+
"danger-full-access": SandboxMode.DANGER_FULL_ACCESS,
70+
}
71+
approval_map = {
72+
"untrusted": ApprovalPolicy.UNTRUSTED,
73+
"on-failure": ApprovalPolicy.ON_FAILURE,
74+
"on-request": ApprovalPolicy.ON_REQUEST,
75+
"never": ApprovalPolicy.NEVER,
76+
}
77+
78+
cfg = CodexConfig(
79+
model=args.model,
80+
include_apply_patch_tool=bool(args.allow_apply_patch), # default: disabled
81+
)
82+
client = CodexClient(config=cfg)
83+
conv = client.start_conversation()
84+
85+
# Send the initial turn with per‑turn overrides.
86+
conv.submit_user_turn(
87+
args.prompt,
88+
sandbox_mode=sandbox_map[args.sandbox],
89+
approval_policy=approval_map[args.approval],
90+
)
91+
92+
print("[codex] streaming events...", flush=True)
93+
for ev in conv:
94+
et = _etype(ev)
95+
96+
if et == "session_configured":
97+
model = getattr(getattr(ev.msg, "root", ev.msg), "model", "?")
98+
print(f"session configured (model={model})")
99+
100+
elif et == "agent_message":
101+
msg = getattr(getattr(ev.msg, "root", ev.msg), "message", "")
102+
print(f"assistant: {msg}")
103+
104+
elif et == "agent_message_delta":
105+
delta = getattr(getattr(ev.msg, "root", ev.msg), "delta", "")
106+
print(delta, end="", flush=True)
107+
108+
# Tool-call logging: MCP tools, exec, web search, and patch apply
109+
elif et == "mcp_tool_call_begin":
110+
root = getattr(ev.msg, "root", ev.msg)
111+
inv = getattr(root, "invocation", None)
112+
server = getattr(inv, "server", "?")
113+
tool = getattr(inv, "tool", "?")
114+
mcp_args = getattr(inv, "arguments", None)
115+
call_id = getattr(root, "call_id", "?")
116+
print(f"[tool] mcp begin id={call_id} {server}:{tool} args={mcp_args}")
117+
118+
elif et == "mcp_tool_call_end":
119+
root = getattr(ev.msg, "root", ev.msg)
120+
inv = getattr(root, "invocation", None)
121+
server = getattr(inv, "server", "?")
122+
tool = getattr(inv, "tool", "?")
123+
call_id = getattr(root, "call_id", "?")
124+
duration = getattr(root, "duration", None)
125+
res = getattr(root, "result", None)
126+
ok = getattr(res, "Ok", None)
127+
err = getattr(res, "Err", None)
128+
if err is not None:
129+
print(
130+
f"[tool] mcp end id={call_id} {server}:{tool} ERROR: {err} (duration={duration})"
131+
)
132+
else:
133+
# Avoid printing large payloads; just show a short summary if present
134+
out_type = getattr(ok, "type", None) if ok is not None else None
135+
print(
136+
f"[tool] mcp end id={call_id} {server}:{tool} ok type={out_type} (duration={duration})"
137+
)
138+
139+
elif et == "exec_command_begin":
140+
root = getattr(ev.msg, "root", ev.msg)
141+
cmd = getattr(root, "command", [])
142+
cwd = getattr(root, "cwd", "")
143+
call_id = getattr(root, "call_id", "?")
144+
print(f"[tool] exec begin id={call_id} cwd={cwd} cmd={' '.join(cmd)}")
145+
146+
elif et == "exec_command_end":
147+
root = getattr(ev.msg, "root", ev.msg)
148+
call_id = getattr(root, "call_id", "?")
149+
exit_code = getattr(root, "exit_code", "?")
150+
duration = getattr(root, "duration", None)
151+
stdout = getattr(root, "stdout", "") or ""
152+
stderr = getattr(root, "stderr", "") or ""
153+
print(
154+
f"[tool] exec end id={call_id} exit={exit_code} duration={duration} stdout={len(stdout)}B stderr={len(stderr)}B"
155+
)
156+
157+
elif et == "web_search_begin":
158+
call_id = getattr(getattr(ev.msg, "root", ev.msg), "call_id", "?")
159+
print(f"[tool] web_search begin id={call_id}")
160+
161+
elif et == "web_search_end":
162+
root = getattr(ev.msg, "root", ev.msg)
163+
call_id = getattr(root, "call_id", "?")
164+
query = getattr(root, "query", "?")
165+
print(f"[tool] web_search end id={call_id} query={query}")
166+
167+
elif et == "patch_apply_begin":
168+
root = getattr(ev.msg, "root", ev.msg)
169+
call_id = getattr(root, "call_id", "?")
170+
auto = getattr(root, "auto_approved", False)
171+
changes = getattr(root, "changes", {}) or {}
172+
print(
173+
f"[tool] patch begin id={call_id} files={len(changes)} auto_approved={bool(auto)}"
174+
)
175+
176+
elif et == "patch_apply_end":
177+
root = getattr(ev.msg, "root", ev.msg)
178+
call_id = getattr(root, "call_id", "?")
179+
success = getattr(root, "success", False)
180+
print(f"[tool] patch end id={call_id} success={bool(success)}")
181+
182+
elif et == "exec_approval_request":
183+
root = getattr(ev.msg, "root", ev.msg)
184+
cmd = getattr(root, "command", [])
185+
cwd = getattr(root, "cwd", "")
186+
print("\n[approval request] exec:", cmd, "in", cwd)
187+
if args.auto_approve or _prompt_yes_no("Approve exec? [y/N] "):
188+
conv.approve_exec(ev.id, ReviewDecision.approved)
189+
else:
190+
conv.approve_exec(ev.id, ReviewDecision.denied)
191+
192+
elif et == "apply_patch_approval_request":
193+
print("\n[approval request] apply_patch")
194+
if args.auto_approve or _prompt_yes_no("Approve patch? [y/N] "):
195+
conv.approve_patch(ev.id, ReviewDecision.approved)
196+
else:
197+
conv.approve_patch(ev.id, ReviewDecision.denied)
198+
199+
elif et == "task_complete":
200+
last = getattr(getattr(ev.msg, "root", ev.msg), "last_agent_message", None)
201+
if last:
202+
print("\n[task complete]", last)
203+
# Either exit immediately or allow the user to continue the conversation
204+
if args.exit_on_complete:
205+
conv.shutdown()
206+
else:
207+
try:
208+
nxt = input("\nAnother prompt (blank to quit): ").strip()
209+
except (EOFError, KeyboardInterrupt):
210+
nxt = ""
211+
if nxt:
212+
conv.submit_user_turn(nxt)
213+
else:
214+
conv.shutdown()
215+
216+
elif et == "shutdown_complete":
217+
print("[codex] shutdown complete")
218+
break
219+
220+
elif et == "stream_error":
221+
root = getattr(ev.msg, "root", ev.msg)
222+
print("[stream error]", getattr(root, "message", "?"))
223+
224+
return 0
225+
226+
227+
def _prompt_yes_no(prompt: str) -> bool:
228+
try:
229+
ans = input(prompt).strip().lower()
230+
return ans in {"y", "yes"}
231+
except (EOFError, KeyboardInterrupt):
232+
return False
233+
234+
235+
if __name__ == "__main__": # pragma: no cover
236+
raise SystemExit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)