Files
My-Brain-Is-Full-Crew/adapters/codex-cli/adapter.sh
gnekt 1b9450aa03 fix: review corrections for Codex CLI adapter (PR #35)
Great work on the Codex CLI integration, thanks for putting this together!
After reviewing the PR against the actual Codex CLI docs and source code,
I found a few things that needed fixing. Here is what changed and why.

## request_user_input is a real Codex CLI tool

The adapter was removing `request_user_input` and replacing it with "ask
the user directly in chat". But `request_user_input` is a native Codex CLI
tool (like `shell` or `spawn_agent`). The fix: `AskUserQuestion` now maps
to `request_user_input` instead of being erased, and `request_user_input`
is preserved everywhere (adapter, docs, compat reference, tests).

## Fictional model names replaced with real ones

The config profiles used `gpt-5.4`, `gpt-5.4-mini`, and
`gpt-5.3-codex-spark`, which do not exist. Replaced with `o3` and
`o4-mini`, which are the current production models for Codex CLI.

## TOML key quoting (security)

`_cc_toml_quote_key` was sanitizing server names by replacing spaces with
hyphens (`Google Calendar` -> `Google-Calendar`). This broke cross-platform
parity because `mcp/servers.yaml` is the source of truth for all platforms.
The fix: the function now properly quotes keys per TOML spec when they
contain spaces or special characters, so `Google Calendar` stays as-is in
YAML and becomes `[mcp_servers."Google Calendar"]` in TOML output.

## servers.yaml breaking change reverted

The PR renamed `Google Calendar` to `Google-Calendar` in servers.yaml.
Since this file feeds all four platform adapters, that rename would break
Claude Code, Gemini CLI, and OpenCode builds. Reverted.

## TOML agent schema in migration doc was wrong

The example in codex-migration.md used a nested `[agent]` /
`[agent.prompt].content` structure. Codex CLI actually uses top-level keys:
`name`, `description`, `developer_instructions`. Fixed the example.

## Security hardening

- Path traversal guard on agent names: rejects `/` and `..` sequences
- Control character rejection in TOML key quoting (newline/CR/tab)
- Newline and tab escaping in all three TOML string escape functions
- Fixed glob expansion risk in `_cc_capabilities_to_sandbox` (now uses
  `read -ra` array instead of unquoted word splitting)

## Docs and smoke matrix

- codex-cli.md: fixed tool mapping table, replaced `@Agent` syntax with
  natural language prompts (Codex CLI does not support @ mentions), added
  note about dispatcher routing
- codex-cli-compat.md: added Read/Glob/Grep/Bash tool mappings
- README.md: added codex-cli adapter and docs to the project structure tree

## Test updates

- ~15 tests updated to match the new semantics (request_user_input
  preserved, model names, POSIX find instead of GNU -printf)
- 119/123 tests pass; the 4 remaining failures are pre-existing (bash 3.2
  on macOS lacks `mapfile` and `declare -A`)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 21:25:12 +02:00

712 lines
29 KiB
Bash

#!/usr/bin/env bash
# =============================================================================
# adapters/codex-cli/adapter.sh — Codex CLI framework adapter
# =============================================================================
# Sourced by scripts/build.sh AFTER adapters/lib.sh.
# Translates source files into a dist/codex-cli/ tree that mirrors what
# Codex CLI expects in the user's vault.
#
# Codex CLI specifics:
# - Dispatcher file: AGENTS.md (same as OpenCode)
# - Platform config dir: .codex/
# - MCP config: .codex/config.toml (TOML format with [mcp_servers.*] tables)
# - Skills dir: .agents/skills/<name>/SKILL.md
# =============================================================================
CC_PLATFORM="codex-cli"
CC_FW_DIR="codex"
CC_DISPATCHER="AGENTS.md"
# rewrite_codex_paths <file>
# Applies Codex-specific targeted path rewrites in-place.
# This is narrower than the shared rewrite_platform_paths helper because Codex
# uses mixed destination roots depending on the source path.
rewrite_codex_paths() {
local file="$1"
[[ -f "$file" ]] || return 0
perl -i -pe '
s|\.platform/agents/|.codex/agents/|g;
s|\.platform/references/|.codex/references/|g;
s|\.platform/skills/|.agents/skills/|g;
s|\.platform/|.codex/|g;
s|DISPATCHER\.md|AGENTS.md|g;
' "$file"
}
# rewrite_tool_compat <file>
# Rewrites unsupported or platform-specific tool references in a file to
# Codex-compatible neutral language. Applied AFTER rewrite_platform_paths so
# path references are already resolved.
#
# Rewrites applied (T-01-06):
# - `AskUserQuestion` / AskUserQuestion → `request_user_input` (Codex native equivalent)
# - "Skill tool" → "invoke the skill"
# - "Agent tool" → "invoke the agent"
# - "Read tool" → "read files"
# - "Glob tool" → "search files"
# - "Grep tool" → "search files"
# - "Bash tool" → "shell"
#
# NOTE: `request_user_input` is a real Codex CLI tool — do NOT rewrite it.
rewrite_tool_compat() {
local file="$1"
[[ -f "$file" ]] || return 0
# Use perl for reliable in-place multi-substitution across all platforms.
# Each substitution is a plain string replacement (no regex heavy-lifting).
# request_user_input is intentionally NOT rewritten — it exists in Codex CLI.
perl -i -pe '
s/`AskUserQuestion`/`request_user_input`/g;
s/\bAskUserQuestion\b/request_user_input/g;
s/\bSkill tool\b/invoke the skill/g;
s/\bAgent tool\b/invoke the agent/g;
s/\bRead tool\b/read files/g;
s/\bGlob tool\b/search files/g;
s/\bGrep tool\b/search files/g;
s/\bBash tool\b/shell/g;
' "$file"
}
# normalize_codex_routing_contract <file>
# Rewrites copied dispatcher/reference content so the generated Codex output
# describes one coherent contract: root-context orchestration, bounded child
# agents, and direct-chat confirmations under agents.max_depth = 1.
normalize_codex_routing_contract() {
local file="$1"
[[ -f "$file" ]] || return 0
rewrite_tool_compat "$file"
perl -0pi -e '
s/handle complex, multi-step, or conversational flows\. Invoke them via the \*\*invoke the skill\*\*\. They run in the main conversation context \(multi-turn state is preserved\)\./handle complex, multi-step, or conversational flows. In Codex, the root context follows the relevant skill instructions directly so multi-turn state stays in one chat./g;
s/handle reactive, single-shot operations\. Invoke them via the \*\*invoke the agent\*\*\. They run as subprocesses\./handle reactive, bounded tasks. In Codex, the root context may spawn a bounded child agent when delegation is worth it, but orchestration decisions stay in the root context because `agents.max_depth = 1`./g;
s/If a user message matches a skill trigger, the skill is invoked via the \*\*invoke the skill\*\* \(not the invoke the agent\)\. The dispatcher does NOT also invoke the source agent\./If a user message matches a skill trigger, the root context follows that skill directly in the main conversation. Do not also spawn a child agent for the same trigger./g;
s/Skills run in the \*\*main conversation context\*\*, preserving multi-turn state\. This is different from agents, which run as subprocesses\./Skills stay in the main conversation context so multi-turn state remains in one chat. Child agents are only for bounded side tasks./g;
s/check registry, check call chain, max depth 3/check the registry and decide in the root context whether another bounded child task is still necessary under `agents.max_depth = 1`/g;
s/Skills count as step 1 in the call chain when they produce agent suggestions\./Skills do not consume extra child depth because the root context executes them directly./g;
s/The dispatcher should confirm with the user first:/The root context should confirm with the user directly in chat first:/g;
s/asks the user if they want the Architect to create a custom agent/asks the user directly in chat whether they want a custom agent/g;
s/"Call chain so far: \[scribe, architect\]\. You are step 3 of max 3\."/"Root context history: [scribe, architect]. If another bounded child task is still useful, the root context decides whether to spawn it within `agents.max_depth = 1`."/g;
s/\*\*Max depth: 3\*\*: no more than 3 agents per user request/\*\*Child depth: `agents.max_depth = 1`\*\*: a spawned child may finish one bounded task, then the root context decides what happens next/g;
s/If the dispatcher would need a 4th agent, it:/If deeper recursion would be required, the root context returns the current results to the user and decides the next step in chat:/g;
s/\bmax depth 3\b/`agents.max_depth = 1`/g;
s/\bstep 3 of max 3\b/root-context follow-up after a bounded child task/g;
s/\bmax depth reached\b/root-context delegation limit reached/g;
s/No duplicates: never invoke the same agent twice in one chain/No duplicates: do not spawn the same child agent twice for the same request/g;
s/No circular patterns: if Agent A suggests Agent B and B is already in the chain, skip/No circular patterns: if a suggested child is already in the root decision history, skip it/g;
s/Do NOT call other agents/Do NOT spawn or imply deeper child recursion/g;
s/only the dispatcher invokes agents/only the root context decides when to spawn a bounded child agent/g;
' "$file"
perl -0pi -e '
s/\binvoke the skill\b/follow the skill instructions directly in the root context/g;
s/\binvoke the agent\b/spawn a bounded child agent from the root context/g;
' "$file"
}
# _cc_prepend_codex_header <file>
# Prepends the Codex-specific routing workaround header to a file (T-01-08).
# Uses a unique marker (<!-- CODEX-ROUTING-HEADER -->) so tests can detect it
# and so repeated builds are idempotent.
_cc_prepend_codex_header() {
local file="$1"
[[ -f "$file" ]] || return 0
# Idempotency: skip if header is already present
grep -qF '<!-- CODEX-ROUTING-HEADER -->' "$file" && return 0
local header
header="$(cat <<'HEADER'
<!-- CODEX-ROUTING-HEADER -->
<!-- Generated by adapters/codex-cli/adapter.sh — do not edit manually -->
## Codex CLI — Routing Notes
> **Context:** Codex CLI loads this file as the root dispatcher. Due to
> multi-agent depth limits (`agents.max_depth = 1` by default), all
> orchestration decisions stay in this root context. Spawned agents emit
> a `### Suggested next agent` signal; the root context then decides whether
> to continue with another agent.
**Custom agents** live in `.codex/agents/*.toml` and are discovered automatically.
**Skills** live in `.agents/skills/` inside the project (or `$HOME/.agents/skills/`).
**Compatibility guide:** see `.codex/references/codex-cli-compat.md` for source-to-Codex mappings, approval wording, and troubleshooting.
**Tool language:** Use neutral, conceptual wording — "run shell commands",
"edit files", "spawn sub-agents", "wait for sub-agents" — rather than
hard-coded tool identifiers that may not exist across all Codex versions.
---
HEADER
)"
# Prepend header before existing content
local tmp; tmp="$(mktemp)"
printf '%s\n' "$header" > "$tmp"
cat "$file" >> "$tmp"
mv "$tmp" "$file"
}
# adapter_translate_dispatcher <source_dispatcher_md> <dest_dir>
# Copies the source DISPATCHER.md to dest_dir/AGENTS.md (Codex CLI's vault-root
# dispatcher filename). Rewrites .platform/ and DISPATCHER.md to codex paths,
# normalizes Codex routing semantics, and prepends the Codex routing header.
adapter_translate_dispatcher() {
local src="$1" dst="$2"
[[ -f "$src" ]] || return 0
mkdir -p "$dst"
cp "$src" "$dst/AGENTS.md"
rewrite_codex_paths "$dst/AGENTS.md"
normalize_codex_routing_contract "$dst/AGENTS.md"
_cc_prepend_codex_header "$dst/AGENTS.md"
}
# adapter_translate_references <source_refs_dir> <dest_root>
# Copies *.md into dest_root/.codex/references/, rewriting framework paths
# and normalizing Codex routing semantics.
adapter_translate_references() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local out="$dst/.codex/references"
mkdir -p "$out"
for f in "$src"/*.md; do
[[ -f "$f" ]] || continue
should_include "$f" "$CC_PLATFORM" || continue
local out_file="$out/$(basename "$f")"
cp "$f" "$out/"
rewrite_codex_paths "$out_file"
if [[ "$(basename "$f")" == "codex-cli-compat.md" ]]; then
continue
fi
normalize_codex_routing_contract "$out_file"
done
}
# adapter_translate_skills <source_skills_dir> <dest_root>
# Copies each skill directory into dest_root/.agents/skills/<name>/, preserving
# optional subdirectories and rewriting text content for Codex-native output.
_cc_rewrite_skill_markdown() {
local skill_name="$1" file="$2"
[[ -f "$file" ]] || return 0
rewrite_codex_paths "$file"
rewrite_tool_compat "$file"
case "$skill_name" in
onboarding)
# After rewrite_tool_compat, AskUserQuestion → request_user_input.
# Now normalise the phrasing around request_user_input for Codex's
# conversational flow (one question at a time, resume from state file).
perl -0pi -e '
s/You MUST use the `?request_user_input`? tool for EVERY question in every phase\. This is not optional\. This is how the onboarding works:/Use `request_user_input` for every question in every phase. Ask one question at a time, wait for the user'\''s reply before continuing, and resume from the saved state file if the flow is already active. This is not optional. This is how the onboarding works:/g;
s/1\. Ask ONE question using `?request_user_input`?/1. Ask one question using `request_user_input`/g;
s/4\. Ask the NEXT question using `?request_user_input`?/4. Ask the next question using `request_user_input`/g;
s/\*\*ONE question per `?request_user_input`? call\.\*\* Never bundle 2\+ questions in one message\./**One question per `request_user_input` call.** Never bundle 2+ questions in one message./g;
s/\.codex\/agents\/\{name\}\.md/.codex\/agents\/{name}.toml/g;
s/\.mcp\.json/.codex\/config.toml/g;
s/\$HOME\/\.platform\/agents/\$HOME\/.codex\/agents/g;
' "$file"
;;
create-agent)
perl -0pi -e '
s/You MUST use the `?request_user_input`? tool for EVERY question in every phase\. This is not optional\. This is how the conversation works:/Use `request_user_input` for every question in every phase. Ask one direct plain-text question at a time, wait for the user'\''s reply before continuing, and resume from the saved state file if the flow is already active. This is not optional. This is how the conversation works:/g;
s/1\. Ask ONE question using `?request_user_input`?/1. Ask one direct plain-text question using `request_user_input`/g;
s/4\. Ask the NEXT question using `?request_user_input`?/4. Ask the next question using `request_user_input`/g;
s/\*\*ONE question per `?request_user_input`? call\.\*\* Never bundle 2\+ questions\./**One question per `request_user_input` call.** Never bundle 2+ questions./g;
s/\.codex\/agents\/\{name\}\.md/.codex\/agents\/{name}.toml/g;
' "$file"
;;
manage-agent)
perl -0pi -e '
s/using `request_user_input`/using `request_user_input`/g;
s/Use `request_user_input` to/Use `request_user_input` to/g;
s/If the user specifies a name, read `\.codex\/agents\/\{name\}\.md`/If the user specifies a name, read `\.codex\/agents\/{name}.toml`/g;
s/Modify the agent file at `\.codex\/agents\/\{name\}\.md`/Modify the agent file at `\.codex\/agents\/{name}.toml`/g;
s/locate `\.codex\/agents\/\{name\}\.md`/locate `\.codex\/agents\/{name}.toml`/g;
s/Delete the agent file from `\.codex\/agents\/\{name\}\.md`/Delete the agent file from `\.codex\/agents\/{name}.toml`/g;
' "$file"
if ! grep -qi 'resume from the saved state file if the flow is already active' "$file"; then
cat <<'EOF' >> "$file"
## Codex Conversation Flow
- Use `request_user_input` when clarification is required.
- Ask one question at a time, wait for the user's reply before continuing.
- Resume from the saved state file if the flow is already active.
EOF
fi
;;
transcribe)
perl -0pi -e '
s/Use `?request_user_input`? to collect:/Use `request_user_input` to collect this intake context: ask one direct plain-text question at a time, wait for the user'\''s reply before continuing, and resume from the saved state file if the flow is already active. Collect:/g;
' "$file"
;;
esac
}
_cc_rewrite_skill_text_tree() {
local skill_name="$1" root="$2"
[[ -d "$root" ]] || return 0
if [[ -f "$root/SKILL.md" ]]; then
_cc_rewrite_skill_markdown "$skill_name" "$root/SKILL.md"
fi
if [[ -d "$root/references" ]]; then
while IFS= read -r ref; do
rewrite_codex_paths "$ref"
rewrite_tool_compat "$ref"
done < <(find "$root/references" -type f -name '*.md')
fi
if [[ -d "$root/assets" ]]; then
while IFS= read -r asset; do
rewrite_codex_paths "$asset"
rewrite_tool_compat "$asset"
done < <(find "$root/assets" -type f -name '*.md')
fi
if [[ -f "$root/agents/openai.yaml" ]]; then
rewrite_codex_paths "$root/agents/openai.yaml"
rewrite_tool_compat "$root/agents/openai.yaml"
fi
}
adapter_translate_skills() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
for skill_dir in "$src"/*/; do
[[ -f "${skill_dir}SKILL.md" ]] || continue
should_include "${skill_dir}SKILL.md" "$CC_PLATFORM" || continue
local name; name="$(basename "$skill_dir")"
local out="$dst/.agents/skills/$name"
mkdir -p "$out"
cp "${skill_dir}SKILL.md" "$out/SKILL.md"
[[ -d "${skill_dir}scripts" ]] && mkdir -p "$out/scripts" && cp -R "${skill_dir}scripts/." "$out/scripts/"
[[ -d "${skill_dir}references" ]] && mkdir -p "$out/references" && cp -R "${skill_dir}references/." "$out/references/"
[[ -d "${skill_dir}assets" ]] && mkdir -p "$out/assets" && cp -R "${skill_dir}assets/." "$out/assets/"
if [[ -f "${skill_dir}agents/openai.yaml" ]]; then
mkdir -p "$out/agents"
cp "${skill_dir}agents/openai.yaml" "$out/agents/openai.yaml"
fi
_cc_rewrite_skill_text_tree "$name" "$out"
done
}
# _cc_toml_quote_key <name>
# Emits the TOML table header for [mcp_servers.<name>].
# If the name contains only bare-key chars (A-Za-z0-9_-), it is emitted
# unquoted. Otherwise it is wrapped in double-quotes with backslash and
# double-quote characters escaped, per TOML spec §3.1.
#
# Security: never interpolate unsanitized user input into TOML keys — this
# function is the single gate for all MCP server names.
_cc_toml_quote_key() {
local name="$1"
# Security: reject control characters (newlines, tabs) that would break
# the TOML table header across lines and enable injection.
if [[ "$name" == *$'\n'* ]] || [[ "$name" == *$'\r'* ]] || [[ "$name" == *$'\t'* ]]; then
echo "# ERROR: server name contains control characters — skipped: $name" >&2
return 1
fi
if [[ "$name" =~ ^[A-Za-z0-9_-]+$ ]]; then
# Bare key — safe as-is
printf '[mcp_servers.%s]' "$name"
else
# Quoted key — escape \ and " per TOML basic-string rules
local escaped
escaped="${name//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
printf '[mcp_servers."%s"]' "$escaped"
fi
}
# _cc_toml_escape_string <value>
# Escapes a value for use in a TOML double-quoted basic string.
# Escapes backslashes, double-quotes, and control characters (newlines, tabs,
# carriage returns) to prevent line-break injection in single-line TOML values.
_cc_toml_escape_string() {
local val="$1"
# Escape backslash first (before adding new backslashes), then other chars
val="${val//\\/\\\\}"
val="${val//\"/\\\"}"
val="${val//$'\n'/\\n}"
val="${val//$'\r'/\\r}"
val="${val//$'\t'/\\t}"
printf '%s' "$val"
}
# adapter_translate_config <source_mcp_dir> <dest_root>
# Reads mcp/servers.yaml and writes dest_root/.codex/config.toml.
#
# Config structure:
# - Safe baseline Codex settings at the top
# - [mcp_servers.<name>] tables for each server
# - HTTP servers: url = "..." (plus env inline table)
# - Local servers: command = ["...", "..."] (plus env inline table)
#
# Security (T-01-01): TOML table names with spaces/special chars are quoted.
# Security (T-01-02): Safe approval_policy and sandbox_mode are always emitted.
adapter_translate_config() {
local src="$1" dst="$2"
local yaml="$src/servers.yaml"
[[ -f "$yaml" ]] || return 0
local out_dir="$dst/.codex"
mkdir -p "$out_dir"
local out="$out_dir/config.toml"
{
# ── Safe baseline Codex settings (T-01-02) ──────────────────────────────
echo '# Generated by adapters/codex-cli/adapter.sh — do not edit manually'
echo 'approval_policy = "on-request"'
echo 'sandbox_mode = "workspace-write"'
echo 'sandbox_workspace_write.network_access = false'
echo ''
echo '# Named profiles override the top-level defaults.'
echo '# Update these model IDs when newer models become available.'
echo '[profiles.quality]'
echo 'model = "o3"'
echo 'model_reasoning_effort = "high"'
echo ''
echo '[profiles.balanced]'
echo 'model = "o4-mini"'
echo 'model_reasoning_effort = "medium"'
echo ''
echo '[profiles.budget]'
echo 'model = "o4-mini"'
echo 'model_reasoning_effort = "low"'
echo ''
echo '[agents]'
echo 'max_depth = 1'
echo ''
# ── Parse servers.yaml line-by-line and emit TOML tables ────────────────
local current_name="" current_type="" current_url="" current_cmd="" current_env=""
local in_servers=0
_cc_flush_server() {
[[ -z "$current_name" ]] && return 0
local key; key="$(_cc_toml_quote_key "$current_name")"
echo "$key"
if [[ "$current_type" == "local" || ( -z "$current_type" && -n "$current_cmd" ) ]]; then
# Local server: command = ["arg1", "arg2", ...]
# current_cmd is the raw YAML array content e.g. npx, -y, "@anthropic-ai/gmail-mcp"
local cmd_toml
cmd_toml="$(_cc_build_command_array "$current_cmd")"
echo "command = $cmd_toml"
else
# HTTP server: url = "..."
local url_escaped; url_escaped="$(_cc_toml_escape_string "$current_url")"
echo "url = \"$url_escaped\""
fi
# env is only valid for command-based (local) servers; omit for HTTP servers
if [[ "$current_type" == "local" || ( -z "$current_type" && -n "$current_cmd" ) ]]; then
if [[ -n "$current_env" ]]; then
echo "env = $(_cc_build_env_table "$current_env")"
fi
fi
echo ''
}
_cc_build_command_array() {
# Convert a comma-separated YAML sequence payload (without brackets) into
# a TOML array of quoted strings: ["npx", "-y", "@anthropic-ai/gmail-mcp"]
local raw="$1"
local items=()
IFS=',' read -ra parts <<< "$raw"
for part in "${parts[@]}"; do
# Trim leading/trailing whitespace and surrounding quotes
part="${part#"${part%%[![:space:]]*}"}"
part="${part%"${part##*[![:space:]]}"}"
part="${part#\"}" ; part="${part%\"}"
part="${part#\'}" ; part="${part%\'}"
items+=("$part")
done
# Build TOML array
local result='['
local first=1
for item in "${items[@]}"; do
[[ -z "$item" ]] && continue
local escaped; escaped="$(_cc_toml_escape_string "$item")"
[[ $first -eq 1 ]] && first=0 || result+=', '
result+="\"$escaped\""
done
result+=']'
echo "$result"
}
_cc_build_env_table() {
local raw="$1"
local result='{'
local first=1
IFS=',' read -ra pairs <<< "$raw"
for pair in "${pairs[@]}"; do
pair="${pair#"${pair%%[![:space:]]*}"}"
pair="${pair%"${pair##*[![:space:]]}"}"
[[ -z "$pair" ]] && continue
local key="${pair%%:*}"
local value="${pair#*:}"
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
key="${key#\"}" ; key="${key%\"}"
value="${value#\"}" ; value="${value%\"}"
local escaped_key escaped_value
escaped_key="$(_cc_toml_escape_string "$key")"
escaped_value="$(_cc_toml_escape_string "$value")"
[[ $first -eq 1 ]] && first=0 || result+=', '
result+="\"$escaped_key\" = \"$escaped_value\""
done
result+='}'
echo "$result"
}
while IFS= read -r line; do
line="${line%$'\r'}"
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line//[[:space:]]/}" ]] && continue
if [[ "$line" =~ ^[[:space:]]*servers:[[:space:]]*$ ]]; then
in_servers=1
elif [[ "$line" =~ ^[[:space:]]*-[[:space:]]name:[[:space:]]*(.+)$ ]]; then
[[ $in_servers -eq 1 ]] && _cc_flush_server
current_name="$(printf '%s' "${BASH_REMATCH[1]}" | tr -d '"')"
current_type=""
current_url=""
current_cmd=""
current_env=""
elif [[ "$line" =~ ^[[:space:]]{4}type:[[:space:]]*(.+)$ ]]; then
current_type="$(printf '%s' "${BASH_REMATCH[1]}" | tr -d '"')"
elif [[ "$line" =~ ^[[:space:]]{4}url:[[:space:]]*(.+)$ ]]; then
current_url="$(printf '%s' "${BASH_REMATCH[1]}" | tr -d '"')"
elif [[ "$line" =~ ^[[:space:]]{4}command:[[:space:]]*\[(.*)\][[:space:]]*$ ]]; then
# Extract the array payload between [ and ]
current_cmd="${BASH_REMATCH[1]}"
elif [[ "$line" =~ ^[[:space:]]{4}env:[[:space:]]*\{(.*)\}[[:space:]]*$ ]]; then
current_env="${BASH_REMATCH[1]}"
fi
done < "$yaml"
_cc_flush_server
} > "$out"
}
# _cc_extract_description <agent_file>
# Extracts the description value from YAML frontmatter, handling both:
# - Scalar: description: Some text
# - Folded block (description: >): captures all indented continuation lines,
# joins them with spaces, and trims the result.
# Emits the description as a single-line string (no leading/trailing whitespace).
_cc_extract_description() {
local file="$1"
awk '
/^---$/ { fm++; next }
fm == 1 && /^description:[[:space:]]*>/ {
# Folded-block style: value starts on next indented lines
in_desc=1
next
}
fm == 1 && /^description:/ {
# Scalar style: description: value on same line
sub(/^description:[[:space:]]*/, "")
desc=$0
in_desc=0
next
}
fm == 1 && in_desc && /^[[:space:]]/ {
# Continuation line (indented)
sub(/^[[:space:]]+/, "")
if (desc == "") desc=$0
else desc=desc " " $0
next
}
fm == 1 && in_desc && !/^[[:space:]]/ {
# End of folded block
in_desc=0
}
fm >= 2 { exit }
END {
# Trim trailing whitespace/period added by folded-block joining
gsub(/[[:space:]]+$/, "", desc)
print desc
}
' "$file"
}
# _cc_toml_escape_dquote_string <value>
# Escapes a string for embedding in a TOML double-quoted basic string.
# Handles backslash, double-quote, and control characters.
_cc_toml_escape_dquote_string() {
local val="$1"
val="${val//\\/\\\\}"
val="${val//\"/\\\"}"
val="${val//$'\n'/\\n}"
val="${val//$'\r'/\\r}"
val="${val//$'\t'/\\t}"
printf '%s' "$val"
}
# _cc_model_to_codex <model>
# Maps the source model tier into a Codex model id.
# Uses current production OpenAI models. Update when newer models ship.
_cc_model_to_codex() {
local model="$1"
case "$model" in
*/*|gpt-*|o[0-9]*) echo "$model" ;;
low) echo "o4-mini" ;;
mid) echo "o4-mini" ;;
high) echo "o3" ;;
*) echo "$model" ;;
esac
}
# _cc_model_reasoning_effort <model>
# Maps the source model tier into a Codex reasoning effort.
_cc_model_reasoning_effort() {
local model="$1"
case "$model" in
high) echo "high" ;;
*) echo "medium" ;;
esac
}
# _cc_capabilities_to_sandbox <space-separated capabilities>
# Preserves the source intent by only granting workspace writes to agents that
# explicitly declare write/edit/bash capabilities.
_cc_capabilities_to_sandbox() {
local caps="$1"
local -a tokens
read -ra tokens <<< "$caps"
for cap in "${tokens[@]}"; do
case "$cap" in
write|edit|bash)
echo "workspace-write"
return 0
;;
esac
done
echo "read-only"
}
# adapter_translate_agent_toml <agent_file> <dest_root>
# Translates a single agent .md file to a Codex CLI custom agent TOML file at:
# <dest_root>/.codex/agents/<basename-without-md>.toml
#
# The TOML file contains:
# name = "<name>"
# description = "<single-line description>"
# developer_instructions = '''
# <body with platform paths rewritten>
# '''
#
# Security (T-01-04): Uses TOML multiline literal strings (''') to avoid
# backslash-escape pitfalls. Falls back to multiline basic strings (""") if
# the body contains the ''' delimiter sequence, escaping as needed.
adapter_translate_agent_toml() {
local agent_file="$1" dst_root="$2"
[[ -f "$agent_file" ]] || return 0
local name; name="$(parse_frontmatter "$agent_file" name)"
[[ -z "$name" ]] && name="$(basename "$agent_file" .md)"
# Security: reject path-separator characters and traversal sequences to
# prevent writes outside the target agents directory.
if [[ "$name" == */* ]] || [[ "$name" == *..* ]] || [[ -z "$name" ]]; then
echo "ERROR: unsafe agent name rejected: '$name'" >&2
return 1
fi
local description; description="$(_cc_extract_description "$agent_file")"
local desc_escaped; desc_escaped="$(_cc_toml_escape_dquote_string "$description")"
local model_raw; model_raw="$(parse_frontmatter "$agent_file" model)"
local model; model="$(_cc_model_to_codex "$model_raw")"
local model_reasoning_effort; model_reasoning_effort="$(_cc_model_reasoning_effort "$model_raw")"
local capabilities; capabilities="$(parse_capabilities "$agent_file")"
local sandbox_mode; sandbox_mode="$(_cc_capabilities_to_sandbox "$capabilities")"
# Get the agent body, apply Codex path rewrites, and then neutralize tool naming.
local body; body="$(agent_body "$agent_file")"
local _body_tmp; _body_tmp="$(mktemp)"
printf '%s\n' "$body" > "$_body_tmp"
rewrite_codex_paths "$_body_tmp"
rewrite_tool_compat "$_body_tmp"
body="$(cat "$_body_tmp")"
rm -f "$_body_tmp"
local out_dir="$dst_root/.codex/agents"
mkdir -p "$out_dir"
local out_file="$out_dir/${name}.toml"
# Choose multiline literal (''') vs basic (""") based on body content (T-01-04)
if [[ "$body" == *"'''"* ]]; then
# Fallback: use multiline basic string, escape backslashes and double-quotes
local escaped_body; escaped_body="${body//\\/\\\\}"
escaped_body="${escaped_body//\"/\\\"}"
{
printf 'name = "%s"\n' "$(_cc_toml_escape_dquote_string "$name")"
printf 'description = "%s"\n' "$desc_escaped"
printf 'model = "%s"\n' "$(_cc_toml_escape_dquote_string "$model")"
printf 'model_reasoning_effort = "%s"\n' "$model_reasoning_effort"
printf 'sandbox_mode = "%s"\n' "$sandbox_mode"
printf 'developer_instructions = """\n'
printf '%s\n' "$escaped_body"
printf '"""\n'
} > "$out_file"
else
# Preferred: multiline literal string — no escaping needed
{
printf 'name = "%s"\n' "$(_cc_toml_escape_dquote_string "$name")"
printf 'description = "%s"\n' "$desc_escaped"
printf 'model = "%s"\n' "$(_cc_toml_escape_dquote_string "$model")"
printf 'model_reasoning_effort = "%s"\n' "$model_reasoning_effort"
printf 'sandbox_mode = "%s"\n' "$sandbox_mode"
printf "developer_instructions = '''\n"
printf '%s\n' "$body"
printf "'''\n"
} > "$out_file"
fi
}
# adapter_translate_agents_toml <source_agents_dir> <dest_root>
# Iterates over all *.md agent files and calls adapter_translate_agent_toml for each.
adapter_translate_agents_toml() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
while IFS= read -r agent; do
[[ -f "$agent" ]] || continue
should_include "$agent" "$CC_PLATFORM" || continue
adapter_translate_agent_toml "$agent" "$dst"
done < <(enumerate_agents "$src")
}
# adapter_build <source_dir> <dest_dir>
# The single entry point invoked by scripts/build.sh.
# Writes into $DIST_DIR/codex-cli/ (dest_dir is already platform-scoped).
adapter_build() {
local src="$1" dst="$2"
local OUT_DIR="$dst"
# Delete and recreate the platform output directory (scoped; T-01-03 accepted)
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
adapter_translate_dispatcher "$src/DISPATCHER.md" "$OUT_DIR"
adapter_translate_references "$src/references" "$OUT_DIR"
adapter_translate_skills "$src/skills" "$OUT_DIR"
adapter_translate_config "$src/mcp" "$OUT_DIR"
adapter_translate_agents_toml "$src/agents" "$OUT_DIR"
}