diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2dfe125 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +*.yaml text eol=lf +*.yml text eol=lf diff --git a/.gitignore b/.gitignore index 02597a3..0540e0d 100755 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ Test-SecondBrain/ *.zip # Dev config .claude/settings.local.json -.mcp.json \ No newline at end of file +.mcp.json +# GSD planning artifacts (internal workflow — not for upstream) +.planning/ \ No newline at end of file diff --git a/README.md b/README.md index cce5f59..f76cc97 100755 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ Claude Code Gemini CLI OpenCode + Codex CLI

- One codebase. Three platforms. Same crew. + One codebase. Four platforms. Same crew.

@@ -253,9 +254,12 @@ The Crew works on multiple agent platforms. The installer builds from a single s | **Claude Code** (CLI & Desktop) | `bash scripts/launchme.sh --platform claude-code` | `.claude/` | `CLAUDE.md` | | **Gemini CLI** | `bash scripts/launchme.sh --platform gemini-cli` | `.gemini/` | `GEMINI.md` | | **OpenCode** | `bash scripts/launchme.sh --platform opencode` | `.opencode/` | `AGENTS.md` | +| **Codex CLI** | `bash scripts/launchme.sh --platform codex-cli` | `.codex/ + .agents/` | `AGENTS.md` | If you omit `--platform`, the installer asks you to choose. Each platform gets agents, skills, references, hooks, and MCP servers translated to its native format. `launchme.sh` installs everything automatically. +> **Codex CLI on Windows:** Codex CLI's Windows support is experimental. Running inside WSL (Windows Subsystem for Linux) is strongly recommended. See [docs/codex-cli.md](docs/codex-cli.md) for details. + Your vault follows a hybrid **PARA + Zettelkasten** structure: ``` @@ -380,7 +384,7 @@ The `/contact-sync` skill syncs contacts to Apple Contacts on macOS. It requires - **[@griches/apple-contacts-mcp](https://www.npmjs.com/package/@griches/apple-contacts-mcp)** — an MCP server that provides full CRUD access to Apple Contacts (search, create, update, delete contacts and groups). -Add it to your `.mcp.json` inside the `mcpServers` object: +**Claude Code / Gemini CLI / OpenCode** — add it to your `.mcp.json` inside the `mcpServers` object: ```json { @@ -393,6 +397,8 @@ Add it to your `.mcp.json` inside the `mcpServers` object: } ``` +**Codex CLI** — add it to `.codex/config.toml` instead (Codex uses TOML, not `.mcp.json`). Follow the instructions in [docs/codex-cli.md](docs/codex-cli.md) for the correct `[mcp_servers.apple-contacts]` table format. + Once connected, the `/contact-sync` skill auto-syncs contacts when you reply to emails or on demand ("sync contact", "add to contacts"). All other agents and skills work with just your local Obsidian vault. No integrations needed. @@ -493,6 +499,23 @@ your-vault/ └── ... your Obsidian notes ``` +Codex CLI uses a split layout instead of a single platform directory: + +``` +your-vault/ +├── .codex/ +│ ├── agents/ ← 8 core agents (.toml format) +│ ├── references/ ← shared docs +│ └── config.toml ← MCP servers + profiles + sandbox policy +├── .agents/ +│ └── skills/ ← 14 specialized skills +├── Meta/ +│ └── scripts/ ← orchestra scripts +├── AGENTS.md ← dispatcher (Codex reads this) +├── My-Brain-Is-Full-Crew/ ← the repo (for updates) +└── ... your Obsidian notes +``` + --- ## Contributing (seriously, please help) @@ -548,5 +571,5 @@ MIT: use it, modify it, share it. Just keep the attribution.

Built by someone who got tired of forgetting things.

- Get Started · Examples · Meet the Agents · Contribute + Get Started · Examples · Codex CLI Guide · Migrate to Codex · Meet the Agents · Contribute

diff --git a/adapters/codex-cli/adapter.sh b/adapters/codex-cli/adapter.sh new file mode 100644 index 0000000..be6c773 --- /dev/null +++ b/adapters/codex-cli/adapter.sh @@ -0,0 +1,676 @@ +#!/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//SKILL.md +# ============================================================================= + +CC_PLATFORM="codex-cli" +CC_FW_DIR="codex" +CC_DISPATCHER="AGENTS.md" + +# rewrite_codex_paths +# 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 +# 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 → "ask the user" (preserves one-at-a-time constraint phrasing elsewhere) +# - `request_user_input` / request_user_input → "ask the user" +# - "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" +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). + perl -i -pe ' + s/`AskUserQuestion`/ask the user/g; + s/\bAskUserQuestion\b/ask the user/g; + s/`request_user_input`/ask the user/g; + s/\brequest_user_input\b/ask the user/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 +# 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; + s/\bask the user\b/ask the user directly in chat and wait for the reply/g; + ' "$file" +} + +# _cc_prepend_codex_header +# Prepends the Codex-specific routing workaround header to a file (T-01-08). +# Uses a unique marker () 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 '' "$file" && return 0 + + local header + header="$(cat <<'HEADER' + + + +## 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 +# 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 +# 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 +# Copies each skill directory into dest_root/.agents/skills//, 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) + perl -0pi -e ' + s/You MUST use the ask the user tool for EVERY question in every phase\. This is not optional\. This is how the onboarding works:/Use direct chat for every question in every phase. Ask one direct plain-text question, 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 ask the user/1. Ask one direct plain-text question/g; + s/2\. Read the user'\''s answer/2. Wait for the user'\''s reply before continuing/g; + s/4\. Ask the NEXT question using ask the user/4. Ask the next direct plain-text question/g; + s/\*\*ONE question per ask the user call\.\*\* Never bundle 2\+ questions in one message\./**One direct plain-text question at a time.** 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 ask the user tool for EVERY question in every phase\. This is not optional\. This is how the conversation works:/Use direct chat for every question in every phase. Ask one direct plain-text question, 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 ask the user/1. Ask one direct plain-text question/g; + s/2\. Read the user'\''s answer/2. Wait for the user'\''s reply before continuing/g; + s/4\. Ask the NEXT question using ask the user/4. Ask the next direct plain-text question/g; + s/\*\*ONE question per ask the user call\.\*\* Never bundle 2\+ questions\./**One direct plain-text question at a time.** Never bundle 2+ questions./g; + s/\.codex\/agents\/\{name\}\.md/.codex\/agents\/{name}.toml/g; + ' "$file" + ;; + manage-agent) + perl -0pi -e ' + s/using `ask the user`/by asking the user directly/g; + s/Use `ask the user` to/Ask one direct plain-text question 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 + +- Ask one direct plain-text question when clarification is required. +- 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 ask the user to collect:/Use direct chat to collect this intake context: ask one direct plain-text question, 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 +# Emits the TOML table key for [mcp_servers.]. +# Codex CLI requires MCP server names to match ^[a-zA-Z0-9_-]+$, so any +# character outside that set is replaced with a hyphen before writing. +_cc_toml_quote_key() { + local name="$1" + # Sanitize: replace any character not in [a-zA-Z0-9_-] with a hyphen + local safe_name="${name//[^a-zA-Z0-9_-]/-}" + printf '[mcp_servers.%s]' "$safe_name" +} + +# _cc_toml_escape_string +# Escapes a value for use in a TOML double-quoted string. +# Escapes backslashes and double-quotes; other characters pass through. +_cc_toml_escape_string() { + local val="$1" + # Escape backslash first, then double-quote + val="${val//\\/\\\\}" + val="${val//\"/\\\"}" + printf '%s' "$val" +} + +# adapter_translate_config +# Reads mcp/servers.yaml and writes dest_root/.codex/config.toml. +# +# Config structure: +# - Safe baseline Codex settings at the top +# - [mcp_servers.] 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 '# Inherit uses the top-level defaults; named profiles override them explicitly.' + echo '[profiles.quality]' + echo 'model = "gpt-5.4"' + echo 'model_reasoning_effort = "high"' + echo '' + echo '[profiles.balanced]' + echo 'model = "gpt-5.4-mini"' + echo 'model_reasoning_effort = "medium"' + echo '' + echo '[profiles.budget]' + echo 'model = "gpt-5.3-codex-spark"' + echo 'model_reasoning_effort = "medium"' + 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 +# 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 +# Escapes a string for embedding in a TOML double-quoted basic string. +# Handles backslash and double-quote characters. +_cc_toml_escape_dquote_string() { + local val="$1" + val="${val//\\/\\\\}" + val="${val//\"/\\\"}" + printf '%s' "$val" +} + +# _cc_model_to_codex +# Maps the source model tier into a Codex model id. +_cc_model_to_codex() { + local model="$1" + case "$model" in + */*|gpt-*) echo "$model" ;; + low) echo "gpt-5.3-codex-spark" ;; + mid) echo "gpt-5.4-mini" ;; + high) echo "gpt-5.4" ;; + *) echo "$model" ;; + esac +} + +# _cc_model_reasoning_effort +# 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 +# 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" + for cap in $caps; do + case "$cap" in + write|edit|bash) + echo "workspace-write" + return 0 + ;; + esac + done + echo "read-only" +} + +# adapter_translate_agent_toml +# Translates a single agent .md file to a Codex CLI custom agent TOML file at: +# /.codex/agents/.toml +# +# The TOML file contains: +# name = "" +# description = "" +# developer_instructions = ''' +# +# ''' +# +# 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)" + + 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 +# 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 +# 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" +} diff --git a/docs/codex-cli.md b/docs/codex-cli.md new file mode 100644 index 0000000..55a9936 --- /dev/null +++ b/docs/codex-cli.md @@ -0,0 +1,219 @@ +# Codex CLI Guide + +This guide covers everything you need to install, update, and run My Brain Is Full — Crew on [Codex CLI](https://openai.com/codex) (`@openai/codex`). + +> **Windows note:** Codex CLI's Windows support is experimental. If you are on Windows, running inside WSL (Windows Subsystem for Linux) is strongly recommended. + +--- + +## Install and update commands + +### First-time install + +```bash +# Install Codex CLI globally +npm i -g @openai/codex@latest + +# Clone the repo inside your vault and install the Crew +cd /path/to/your-vault +git clone https://github.com/gnekt/My-Brain-Is-Full-Crew.git +cd My-Brain-Is-Full-Crew +bash scripts/launchme.sh --platform codex-cli +``` + +The installer accepts an optional `--target` flag if you want to point it at a vault in a non-standard location: + +```bash +bash scripts/launchme.sh --platform codex-cli --target /path/to/your-vault +``` + +### Update after a git pull + +```bash +cd /path/to/your-vault/My-Brain-Is-Full-Crew +git pull +bash scripts/updateme.sh --platform codex-cli +``` + +The updater auto-detects Codex CLI by checking for `.codex/agents` in your vault. If multiple platforms are installed, pass `--platform codex-cli` explicitly. + +--- + +## What installs where + +After running `launchme.sh --platform codex-cli`, your vault will contain: + +``` +your-vault/ +├── .codex/ +│ ├── agents/ ← 8 core crew agents (.toml format) +│ ├── references/ ← shared docs the agents read +│ └── config.toml ← MCP server definitions + profiles + sandbox policy +├── .agents/ +│ └── skills/ ← 14 specialized skills (plain text instructions) +├── Meta/ +│ └── scripts/ ← orchestra scripts (permission-free agent commands) +└── AGENTS.md ← dispatcher (project instructions for Codex) +``` + +Key differences from other platforms: + +| Path | Purpose | +|------|---------| +| `.codex/agents/*.toml` | Custom agent definitions (Codex native format) | +| `.agents/skills/` | Repo-scoped skill instructions (shared discovery path) | +| `.codex/config.toml` | MCP servers, approval policy, sandbox mode, model profiles | +| `AGENTS.md` | Dispatcher — Codex reads this as its primary project instruction file | + +--- + +## Architecture differences from Claude Code, Gemini CLI, and OpenCode + +### Dispatcher + +All platforms use a dispatcher file, but the name and format differ: + +| Platform | Dispatcher file | +|----------|----------------| +| Claude Code | `CLAUDE.md` | +| Gemini CLI | `GEMINI.md` | +| OpenCode | `AGENTS.md` | +| Codex CLI | `AGENTS.md` (with root-context routing header) | + +Codex CLI shares the `AGENTS.md` name with OpenCode but prepends a routing header that handles orchestration within the `agents.max_depth = 1` constraint (see below). + +### Agent format + +Claude Code, Gemini CLI, and OpenCode all use Markdown (`.md`) agent files. Codex CLI uses TOML: + +``` +.claude/agents/architect.md ← Claude Code +.gemini/agents/architect.md ← Gemini CLI +.opencode/agents/architect.md ← OpenCode +.codex/agents/architect.toml ← Codex CLI +``` + +### Skills location + +Skills install to `.agents/skills/` for Codex (not `.codex/skills/`). Codex CLI discovers skills from this shared path. + +### Agent chaining (max_depth constraint) + +Codex CLI enforces `agents.max_depth = 1`. This means child agents can only go one level deep. My Brain Is Full — Crew handles this through root-context orchestration: + +- The dispatcher embeds orchestration instructions in the root context (not in a child) +- Child agents (`spawn_agent`) finish one bounded task and return to root +- Any next step is decided from the root context, not by a nested child + +### Tool name differences + +Codex CLI does not have the `AskUserQuestion` or `request_user_input` tools. The equivalent patterns are: + +| Source concept | Codex CLI equivalent | +|---|---| +| `AskUserQuestion` | Ask a direct question in the chat thread and wait for the reply | +| `request_user_input` | Same — use the root conversation for follow-up questions | +| `Skill tool` | Follow the skill instructions directly in the root context | +| `Agent tool` | Use `spawn_agent` for a bounded child task; orchestration returns to root | +| `max chain depth 3` | `agents.max_depth = 1` with root-only orchestration | +| `.mcp.json` | `.codex/config.toml` | + +### MCP configuration + +Claude Code uses `.mcp.json`. Codex CLI uses `.codex/config.toml`. The MCP server, approval policy, sandbox mode, and model profile settings all live in the TOML config. The CLI and Codex IDE extension share this same config file. + +--- + +## Runtime smoke matrix + +Use this table to verify the Crew works correctly in a real Codex vault after install or update. Run each row and compare the result against the expected outcome. + +| Surface | Name | Prompt or command | Expected result | +|---------|------|-------------------|----------------| +| Agent | Architect | `@Architect Set up my vault structure` | Architect starts onboarding conversation or confirms vault is already set up | +| Agent | Scribe | `@Scribe Save this note: quick test` | Scribe creates a note in 00-Inbox with proper frontmatter | +| Agent | Sorter | `@Sorter Triage my inbox` | Sorter reviews inbox notes and files them, or reports inbox is empty | +| Agent | Seeker | `@Seeker What do I know about this project?` | Seeker searches the vault and returns results with source citations | +| Agent | Connector | `@Connector Find connections in my recent notes` | Connector analyzes the vault graph and suggests wikilinks | +| Agent | Librarian | `@Librarian Run a vault health check` | Librarian scans for broken links, duplicates, and orphan notes | +| Agent | Transcriber | `@Transcriber Process this transcript: [paste text]` | Transcriber generates structured meeting notes | +| Agent | Postman | `@Postman Check my email` | Postman scans Gmail (or Hey) and saves actionable emails, or reports missing integration | +| Skill | onboarding | `/onboarding` | Architect starts the full onboarding conversation | +| Skill | create-agent | `/create-agent` | Architect walks through designing a new custom agent | +| Skill | manage-agent | `/manage-agent` | Architect lists, edits, or removes custom agents | +| Skill | defrag | `/defrag` | Architect runs the 5-phase vault defragmentation | +| Skill | email-triage | `/email-triage` | Postman scans and prioritizes unread emails | +| Skill | meeting-prep | `/meeting-prep` | Postman generates a comprehensive meeting brief | +| Skill | weekly-agenda | `/weekly-agenda` | Postman produces a day-by-day week overview | +| Skill | deadline-radar | `/deadline-radar` | Postman produces a unified deadline timeline | +| Skill | transcribe | `/transcribe` | Transcriber processes a recording or transcript into structured notes | +| Skill | vault-audit | `/vault-audit` | Librarian runs the full 7-phase vault audit | +| Skill | deep-clean | `/deep-clean` | Librarian runs the extended vault cleanup | +| Skill | tag-garden | `/tag-garden` | Librarian analyzes and cleans up tags | +| Skill | inbox-triage | `/inbox-triage` | Sorter processes and routes all inbox notes | +| Skill | contact-sync | `/contact-sync` | Postman syncs contacts to Apple Contacts | +| Chaining | bounded child-agent chain | `@Sorter Triage my inbox` (with notes present that mention a new project) | Sorter files notes, then dispatcher signals Architect to create the new project folder; child returns to root before Architect runs | +| MCP | MCP visibility | `codex -C mcp list` | Lists the MCP servers configured in `.codex/config.toml`, or shows the auth/setup state for each server | + +### Running the non-interactive discovery smoke + +```bash +codex exec -C "List the project custom agents under .codex/agents, the repo skills under .agents/skills, and the dispatcher file used in this workspace." +``` + +Expected output references: +- `AGENTS.md` (the dispatcher) +- `.codex/agents` path (custom agents) +- `.agents/skills` path (repo skills) + +### Running the MCP visibility smoke + +```bash +codex -C mcp list +``` + +Expected: lists MCP servers from `.codex/config.toml` (e.g., `Gmail`, `Calendar`) or shows their auth/setup state. + +--- + +## Troubleshooting + +### Agents are not discovered + +- Verify `.codex/agents/` exists in your vault root and contains `.toml` files. +- Open Codex CLI from your vault directory: `codex -C /path/to/your-vault` +- Check that `AGENTS.md` exists at the vault root (not inside the repo subdirectory). + +### Skills are not available + +- Verify `.agents/skills/` exists in your vault root and contains subdirectories. +- Skills must be at the vault root level: `/.agents/skills//` + +### Child agent chain does not return to root + +- This is a Codex `agents.max_depth = 1` constraint. Child agents can only go one level deep. +- The dispatcher uses root-context orchestration to work within this constraint. +- If a task seems to require deeper nesting, flatten it: complete the first bounded step in a child, then handle the next step in the root context. + +### MCP server not connecting + +- MCP configuration lives in `.codex/config.toml` (not `.mcp.json`). +- Check `codex -C mcp list` to see the current server status. +- For Gmail/Calendar setup, see `docs/gws-setup-guide.md`. +- For Apple Contacts, verify the `apple-contacts` server entry in `.codex/config.toml`. + +### Codex errors about approvals + +- Child agent approvals surface in the child thread. Approve or deny there, then continue orchestration from the root context after the child returns. +- If a task requires deeper recursion, stop spawning children and flatten the next step into the root context or split the work into separate bounded child tasks. + +### Windows users + +Codex CLI's Windows support is experimental. Use WSL (Windows Subsystem for Linux) for the most reliable experience. From WSL, follow the standard Linux install path above. + +### Reinstall vs update + +- **Reinstall** (`launchme.sh`): Use when setting up a new vault or recovering from a broken state. +- **Update** (`updateme.sh`): Use after `git pull` to push new agents, skills, and references to an existing vault. Custom agents are never overwritten. + +For a migration from another platform, see [docs/codex-migration.md](codex-migration.md). diff --git a/docs/codex-migration.md b/docs/codex-migration.md new file mode 100644 index 0000000..ca71a80 --- /dev/null +++ b/docs/codex-migration.md @@ -0,0 +1,172 @@ +# Migrating to Codex CLI + +This guide covers how to move an existing My Brain Is Full — Crew installation from Claude Code, Gemini CLI, or OpenCode to Codex CLI. It also explains what transfers automatically and what needs manual attention. + +> **If you are doing a fresh install** (not migrating), follow [docs/codex-cli.md](codex-cli.md) instead. + +--- + +## When to reinstall vs update + +| Scenario | Recommended action | +|----------|-------------------| +| You have an existing Claude Code / Gemini CLI / OpenCode vault and want to add Codex CLI alongside it | Run `bash scripts/launchme.sh --platform codex-cli` in the same vault — multiple platforms can coexist | +| You want to switch exclusively to Codex CLI | Run `launchme.sh --platform codex-cli`; the other platform files remain but are inactive | +| Your Codex layout is broken or missing files | Run `launchme.sh --platform codex-cli` again — it is idempotent and safe to re-run | +| You pulled new repo changes and want to update Codex | Run `bash scripts/updateme.sh --platform codex-cli` | + +You do not need to remove other platform directories. Codex CLI only reads `.codex/` and `.agents/skills/`; it ignores `.claude/`, `.gemini/`, and `.opencode/`. + +--- + +## Path mapping by platform + +When you switch to Codex CLI, the project files move to new paths. Use this table to locate your existing files and understand where the equivalent lives in Codex. + +| Source platform | Dispatcher | Agents | Skills | MCP or config | Codex target | +|----------------|-----------|--------|--------|--------------|-------------| +| Claude Code | `CLAUDE.md` | `.claude/agents/*.md` | `.claude/skills/` | `.mcp.json` | `AGENTS.md` / `.codex/agents/*.toml` / `.agents/skills/` / `.codex/config.toml` | +| Gemini CLI | `GEMINI.md` | `.gemini/agents/*.md` | `.gemini/skills/` | (none) | `AGENTS.md` / `.codex/agents/*.toml` / `.agents/skills/` / `.codex/config.toml` | +| OpenCode | `AGENTS.md` | `.opencode/agents/*.md` | `.opencode/skills/` | `opencode.json` | `AGENTS.md` / `.codex/agents/*.toml` / `.agents/skills/` / `.codex/config.toml` | + +After running `launchme.sh --platform codex-cli`, the Codex files are installed automatically. You do not need to copy the old platform files manually. + +--- + +## Moving from Claude Code + +1. Pull the latest repo changes: + ```bash + cd /path/to/your-vault/My-Brain-Is-Full-Crew + git pull + ``` + +2. Run the Codex installer: + ```bash + bash scripts/launchme.sh --platform codex-cli + ``` + +3. The installer creates: + - `.codex/agents/` — all 8 core agents in TOML format + - `.agents/skills/` — all 14 skills as plain text instructions + - `.codex/config.toml` — MCP servers (translated from `mcp/servers.yaml`) + - `AGENTS.md` — dispatcher with Codex routing header + +4. Your existing `.claude/` directory and `CLAUDE.md` are left untouched. + +5. MCP configuration: Claude Code uses `.mcp.json`. Codex CLI uses `.codex/config.toml`. If you added custom MCP servers to `.mcp.json` manually, you will need to add them to `.codex/config.toml` as well. See the `[mcp_servers.*]` TOML table format. + +6. Custom agents: Claude Code custom agents live in `.claude/agents/`. Codex CLI custom agents must be in `.toml` format in `.codex/agents/`. Custom agents created via the `/create-agent` skill are not automatically migrated — see [Custom agents and what does not migrate automatically](#custom-agents-and-what-does-not-migrate-automatically). + +--- + +## Moving from Gemini CLI + +1. Pull the latest repo changes: + ```bash + cd /path/to/your-vault/My-Brain-Is-Full-Crew + git pull + ``` + +2. Run the Codex installer: + ```bash + bash scripts/launchme.sh --platform codex-cli + ``` + +3. The installer creates the full Codex layout (same as above). + +4. Your existing `.gemini/` directory and `GEMINI.md` are left untouched. + +5. MCP configuration: Gemini CLI does not use `.mcp.json`. If you have MCP servers configured elsewhere, add them to `.codex/config.toml` manually. + +6. Custom agents: Gemini CLI custom agents live in `.gemini/agents/`. These are Markdown files. For Codex CLI, custom agents must be TOML files in `.codex/agents/`. See [Custom agents and what does not migrate automatically](#custom-agents-and-what-does-not-migrate-automatically). + +--- + +## Moving from OpenCode + +1. Pull the latest repo changes: + ```bash + cd /path/to/your-vault/My-Brain-Is-Full-Crew + git pull + ``` + +2. Run the Codex installer: + ```bash + bash scripts/launchme.sh --platform codex-cli + ``` + +3. The installer creates the full Codex layout. Note that both OpenCode and Codex CLI use `AGENTS.md` as the dispatcher. The installer will overwrite `AGENTS.md` with the Codex-specific version (which includes the root-context routing header). If you are running both platforms from the same vault, be aware that the two platforms share `AGENTS.md`. + +4. Your existing `.opencode/` directory is left untouched. + +5. MCP configuration: OpenCode uses `opencode.json`. Codex CLI uses `.codex/config.toml`. If you added custom MCP servers to `opencode.json`, add them to `.codex/config.toml` manually. + +6. Custom agents: OpenCode custom agents live in `.opencode/agents/` as Markdown files. Codex CLI custom agents must be TOML files in `.codex/agents/`. See [Custom agents and what does not migrate automatically](#custom-agents-and-what-does-not-migrate-automatically). + +--- + +## Custom agents and what does not migrate automatically + +When you run the installer, the 8 core crew agents are automatically translated to Codex TOML format. However, **custom agents you created with `/create-agent`** are not automatically migrated because: + +- They live in your platform's agents directory (`.claude/agents/`, `.gemini/agents/`, etc.) +- They are Markdown files; Codex requires TOML +- The installer never overwrites or deletes files in the agents directory that it did not create + +### To migrate a custom agent manually + +1. Locate your custom agent file (e.g., `.claude/agents/budget-tracker.md`) +2. Open Codex CLI in your vault and run `/create-agent` +3. Describe the agent's purpose — the Architect will guide you through creating a new `.toml` file in `.codex/agents/` +4. Alternatively, create the TOML file manually using one of the generated core agents as a template (e.g., `.codex/agents/scribe.toml`) + +### What the TOML format looks like + +```toml +[agent] +name = "budget-tracker" +description = "Monitors spending notes and flags when you are close to the monthly limit" +model = "o4-mini" + +[agent.prompt] +content = """ +You are the Budget Tracker agent for the My Brain Is Full — Crew system. +... (your agent instructions here) +""" +``` + +--- + +## Verification after migration + +After running the installer, verify the Codex layout with these commands: + +### Check that files installed correctly + +```bash +ls /.codex/agents/ # Should list *.toml files for all 8 agents +ls /.agents/skills/ # Should list subdirectories for all 14 skills +ls /.codex/config.toml # Should exist with [mcp_servers.*] tables +ls /AGENTS.md # Should exist with Codex routing header +``` + +### Run the non-interactive discovery smoke + +```bash +codex exec -C "List the project custom agents under .codex/agents, the repo skills under .agents/skills, and the dispatcher file used in this workspace." +``` + +Expected: response references `AGENTS.md`, `.codex/agents`, and `.agents/skills`. + +### Check MCP visibility + +```bash +codex -C mcp list +``` + +Expected: lists MCP servers from `.codex/config.toml`. + +### Run the full runtime smoke matrix + +See [docs/codex-cli.md — Runtime smoke matrix](codex-cli.md#runtime-smoke-matrix) for the complete list of agents, skills, chaining, and MCP checks. diff --git a/docs/examples.md b/docs/examples.md index 8a8cb24..feda7ab 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -155,6 +155,57 @@ Practical scenarios showing how the Crew works in daily life. Each example shows --- +## Codex CLI session examples + +These examples show how to start and use the Crew with Codex CLI specifically. + +### Install and launch + +```bash +# Install Codex CLI +npm i -g @openai/codex@latest + +# Install the Crew for Codex CLI +bash scripts/launchme.sh --platform codex-cli + +# Launch Codex in your vault +codex -C /path/to/your-vault +``` + +### Verify the layout before your first session + +```bash +# Non-interactive discovery smoke — confirms agents, skills, and dispatcher are visible +codex exec -C /path/to/your-vault "List the project custom agents under .codex/agents, the repo skills under .agents/skills, and the dispatcher file used in this workspace." + +# Check MCP server visibility +codex -C /path/to/your-vault mcp list +``` + +### Using agents and skills inside Codex + +Once inside the interactive `codex` session, the Crew works the same way as on other platforms — just talk naturally: + +``` +"Initialize my vault" → /onboarding skill starts +"Save this note: quick idea" → Scribe agent captures it +"Triage my inbox" → /inbox-triage skill runs +"Check my email" → /email-triage skill scans Gmail +"Weekly review" → /vault-audit skill audits vault +``` + +### Update after a git pull + +```bash +cd /path/to/your-vault/My-Brain-Is-Full-Crew +git pull +bash scripts/updateme.sh --platform codex-cli +``` + +For the full runtime smoke matrix covering all 8 agents, all 14 skills, bounded child-agent chaining, and MCP visibility, see [docs/codex-cli.md](codex-cli.md). + +--- + ## Daily Workflow Cheat Sheet | Time | What to say | Skill/Agent | diff --git a/docs/getting-started.md b/docs/getting-started.md index fe7a085..3548401 100755 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,7 +8,9 @@ A step-by-step guide for setting up your AI-powered vault. No technical backgrou ### Required - **Obsidian**: A free note-taking app. Download it at [obsidian.md](https://obsidian.md) -- **An agent platform**: one of [Claude Code](https://claude.ai/code) (Pro/Max/Team), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [OpenCode](https://opencode.ai). +- **An agent platform**: one of [Claude Code](https://claude.ai/code) (Pro/Max/Team), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [OpenCode](https://opencode.ai), or [Codex CLI](https://openai.com/codex) (`npm i -g @openai/codex`). + + > **Windows + Codex CLI:** Codex CLI's Windows support is experimental. If you plan to use Codex CLI on Windows, run it inside WSL (Windows Subsystem for Linux) for the best experience. - **An Obsidian vault**: This is just a folder on your computer where Obsidian stores your notes. If you don't have one yet, Obsidian will create one for you when you first open it. - **Git**: A tool to download the project. On Mac, the terminal will prompt you to install it automatically the first time you use it. On Windows, download it from [git-scm.com](https://git-scm.com). @@ -66,8 +68,9 @@ Install one of the following: | **Claude Code** | [claude.ai/code](https://claude.ai/code) | Claude Pro, Max, or Team | | **Gemini CLI** | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | Google account | | **OpenCode** | [opencode.ai](https://opencode.ai) | Varies by provider | +| **Codex CLI** | `npm i -g @openai/codex` | OpenAI account | -Claude Code works as both CLI and Desktop app (Cowork). The Crew works on all supported platforms. +Claude Code works as both CLI and Desktop app (Cowork). The Crew works on all four supported platforms. --- @@ -95,19 +98,34 @@ bash scripts/launchme.sh ``` The script will ask a couple of questions: -1. **Which platform?** Select your agent platform (Claude Code, Gemini CLI, or OpenCode) +1. **Which platform?** Select your agent platform (Claude Code, Gemini CLI, OpenCode, or Codex CLI) 2. **Is this your vault folder?** Confirm or enter the correct path When it's done, your vault will look like this (paths vary by platform): ``` your-vault/ -├── ./ ← .claude/, .gemini/, .opencode/, or any platform dir that will be supported in the future +├── ./ ← .claude/, .gemini/, .opencode/ │ ├── agents/ ← 8 lightweight crew agents │ ├── skills/ ← 14 specialized skills for complex flows │ ├── hooks/ ← file protection and validation │ └── references/ ← shared docs the agents read -├── CLAUDE.md / GEMINI.md / AGENTS.md / ... ← dispatcher (varies by platform) +├── CLAUDE.md / GEMINI.md / AGENTS.md ← dispatcher (varies by platform) +├── My-Brain-Is-Full-Crew/ ← the repo (for future updates) +└── ... your Obsidian notes +``` + +**Codex CLI** uses a split layout instead of a single platform directory: + +``` +your-vault/ +├── .codex/ +│ ├── agents/ ← 8 core agents (.toml format) +│ ├── references/ ← shared docs +│ └── config.toml ← MCP servers + profiles + sandbox policy +├── .agents/ +│ └── skills/ ← 14 specialized skills +├── AGENTS.md ← dispatcher ├── My-Brain-Is-Full-Crew/ ← the repo (for future updates) └── ... your Obsidian notes ``` @@ -118,13 +136,18 @@ your-vault/ ## Step 4: Connect your vault -1. Open your agent platform (Claude Code, Gemini CLI, or OpenCode) +1. Open your agent platform (Claude Code, Gemini CLI, OpenCode, or Codex CLI) 2. Open it **inside your Obsidian vault folder**. This is important: the platform needs to be in your vault to read and write your notes. If you're using a CLI tool: ```bash cd /path/to/your-vault -claude # or: gemini, opencode +claude # or: gemini, opencode, codex +``` + +For Codex CLI, you can also use the `-C` flag to point directly at your vault: +```bash +codex -C /path/to/your-vault ``` If you're using Claude Code Desktop (Cowork), open the vault folder as your working directory. @@ -220,7 +243,11 @@ The Crew works best with simple daily routines: Make sure your agent platform is open inside your vault folder (not a different directory). Verify agent files exist in the platform's agents directory (e.g., `.claude/agents/`). Try saying the trigger phrase differently. Agents and skills understand natural language in multiple languages. ### "Email/Calendar isn't working" -The Postman needs at least one email backend: GWS CLI (`gws`), Hey CLI (`hey`), or MCP connectors. For GWS, see `docs/gws-setup-guide.md`. For Hey, install from [github.com/basecamp/hey-cli](https://github.com/basecamp/hey-cli) and run `hey auth login`. For MCP, run the installer again (`bash scripts/launchme.sh`) and answer **yes** to the Gmail/Calendar question, or manually copy `.mcp.json` from the repo to your vault root. +The Postman needs at least one email backend: GWS CLI (`gws`), Hey CLI (`hey`), or MCP connectors. For GWS, see `docs/gws-setup-guide.md`. For Hey, install from [github.com/basecamp/hey-cli](https://github.com/basecamp/hey-cli) and run `hey auth login`. + +For MCP connectors: +- **Claude Code / OpenCode**: run the installer again (`bash scripts/launchme.sh`) and answer **yes** to the Gmail/Calendar question, or manually add the servers to your `.mcp.json` at the vault root. +- **Codex CLI**: MCP servers are configured in `.codex/config.toml` (not `.mcp.json`). Run `bash scripts/launchme.sh --platform codex-cli` and the installer writes them automatically. See [docs/codex-cli.md](codex-cli.md) for the full MCP setup details. ### "My vault structure looks different from the docs" The Architect customizes the structure based on your onboarding answers. @@ -233,6 +260,11 @@ git pull bash scripts/updateme.sh ``` +For Codex CLI specifically: +```bash +bash scripts/updateme.sh --platform codex-cli +``` + Only changed files are updated. Your vault notes are never touched. ### "An agent did something weird" @@ -249,6 +281,8 @@ Open an issue on GitHub with: ## Next steps - **[Examples](examples.md)**: See real-world usage scenarios +- **[Codex CLI Guide](codex-cli.md)**: Install/update guide, architecture differences, runtime smoke matrix, and troubleshooting for Codex CLI +- **[Migrate to Codex CLI](codex-migration.md)**: Step-by-step migration from Claude Code, Gemini CLI, or OpenCode - **[Mobile Access](mobile-access.md)**: Use the Crew from your phone - **[Meet the Agents](agents/)**: Deep-dive into each agent's capabilities - **[Contributing](../CONTRIBUTING.md)**: Help make the Crew better diff --git a/mcp/servers.yaml b/mcp/servers.yaml index fb5a463..8c0f6c3 100755 --- a/mcp/servers.yaml +++ b/mcp/servers.yaml @@ -4,7 +4,7 @@ servers: url: "https://gmail.mcp.claude.com/mcp" env: {} exclude: [] - - name: Google Calendar + - name: Google-Calendar type: http url: "https://gcal.mcp.claude.com/mcp" env: {} diff --git a/references/codex-cli-compat.md b/references/codex-cli-compat.md new file mode 100644 index 0000000..f887054 --- /dev/null +++ b/references/codex-cli-compat.md @@ -0,0 +1,38 @@ +--- +exclude: [claude-code, gemini-cli, opencode] +--- + +# Codex CLI Compatibility Guide + +Use this reference when source workflows mention platform-specific tools or recursion rules that do not map 1:1 to Codex CLI. + +| Source concept | Codex CLI mapping | Notes | +|---|---|---| +| `AskUserQuestion` | Ask a direct plain-text question in chat and wait for the reply | Codex uses the root conversation for confirmations and follow-up questions. | +| `request_user_input` | Ask a direct plain-text question in chat and wait for the reply | Use the same root-thread confirmation flow as any other user interaction. | +| `Skill tool` | Follow the relevant skill instructions directly in the root context | Skills stay in the main chat; do not invent a separate Skill API. | +| `Agent tool` | Use `spawn_agent` only for bounded child tasks | The root context keeps orchestration and integration decisions. | +| `max chain depth 3` | `agents.max_depth = 1` with root-only orchestration | A child can finish one bounded task; any next step is decided back in the root context. | +| `.mcp.json` | `.codex/config.toml` | Codex MCP and profile settings live in the TOML config. | + +## Flattened Workflow Example + +Source workflow wording: + +1. Call `AskUserQuestion` for confirmation. +2. Use the `Skill tool` for the setup flow. +3. Use the `Agent tool` for a follow-up task. + +Codex CLI wording: + +1. Ask the user directly in chat and wait for the reply. +2. Keep the setup flow in the root context by following the skill instructions directly. +3. If a bounded side task remains, use `spawn_agent`, then return to the root context to decide what happens next. + +## Troubleshooting + +- Child approvals surface in the child thread. Approve or deny there, then continue orchestration from the root context after the child returns. +- If a task would require deeper recursion, stop spawning children and flatten the next step into the root context or split the work into separate bounded child tasks. +- Codex custom agents live in `.codex/agents/*.toml`. +- Repo-scoped Codex skills live in `.agents/skills/`. +- MCP servers, approval policy, sandbox mode, and profiles live in `.codex/config.toml`. diff --git a/scripts/build.sh b/scripts/build.sh index 3b63217..4951ead 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -28,7 +28,12 @@ if [[ ! -d "$REPO_ROOT/adapters/$PLATFORM" ]]; then fi # ── Check dependencies ───────────────────────────────────────────────────── -command -v jq >/dev/null 2>&1 || die "jq is required for the build (install via brew, apt, etc.)" +case "$PLATFORM" in + codex-cli) ;; + *) + command -v jq >/dev/null 2>&1 || die "jq is required for the build (install via brew, apt, etc.)" + ;; +esac # ── Source adapters ──────────────────────────────────────────────────────── # shellcheck source=adapters/lib.sh diff --git a/scripts/launchme.sh b/scripts/launchme.sh index d7c275e..6453716 100755 --- a/scripts/launchme.sh +++ b/scripts/launchme.sh @@ -102,6 +102,7 @@ EXISTING=0 [[ -d "$VAULT_DIR/.claude" ]] && EXISTING=1 [[ -d "$VAULT_DIR/.opencode" ]] && EXISTING=1 [[ -d "$VAULT_DIR/.gemini" ]] && EXISTING=1 +[[ -d "$VAULT_DIR/.codex" ]] && EXISTING=1 [[ -f "$VAULT_DIR/CLAUDE.md" ]] && EXISTING=1 [[ -f "$VAULT_DIR/AGENTS.md" ]] && EXISTING=1 [[ -f "$VAULT_DIR/GEMINI.md" ]] && EXISTING=1 @@ -111,6 +112,7 @@ if [[ $EXISTING -eq 1 ]]; then [[ -d "$VAULT_DIR/.claude" ]] && warn " .claude/ directory exists" [[ -d "$VAULT_DIR/.opencode" ]] && warn " .opencode/ directory exists" [[ -d "$VAULT_DIR/.gemini" ]] && warn " .gemini/ directory exists" + [[ -d "$VAULT_DIR/.codex" ]] && warn " .codex/ directory exists" [[ -f "$VAULT_DIR/CLAUDE.md" ]] && warn " CLAUDE.md exists" [[ -f "$VAULT_DIR/AGENTS.md" ]] && warn " AGENTS.md exists" [[ -f "$VAULT_DIR/GEMINI.md" ]] && warn " GEMINI.md exists" @@ -162,11 +164,26 @@ case "$PLATFORM" in MCP_SRC="" MCP_DST="" HAS_PLUGINS=0 + DIST_SKILLS_DIR="$DIST_COMPONENTS_DIR/skills" + VAULT_SKILLS_DIR="$VAULT_COMPONENTS_DIR/skills" + ;; + codex-cli) + DIST_COMPONENTS_DIR="$DIST_DIR/.codex" + VAULT_COMPONENTS_DIR="$VAULT_DIR/.codex" + DISPATCHER_SRC="$DIST_DIR/AGENTS.md" + DISPATCHER_DST="$VAULT_DIR/AGENTS.md" + MCP_SRC="$DIST_DIR/.codex/config.toml" + MCP_DST="$VAULT_DIR/.codex/config.toml" + HAS_PLUGINS=0 + DIST_SKILLS_DIR="$DIST_DIR/.agents/skills" + VAULT_SKILLS_DIR="$VAULT_DIR/.agents/skills" ;; *) die "Unknown platform: $PLATFORM (install layout not defined)" ;; esac +[[ -n "${DIST_SKILLS_DIR:-}" ]] || DIST_SKILLS_DIR="$DIST_COMPONENTS_DIR/skills" +[[ -n "${VAULT_SKILLS_DIR:-}" ]] || VAULT_SKILLS_DIR="$VAULT_COMPONENTS_DIR/skills" PLATFORM_VAULT_DIR="$VAULT_COMPONENTS_DIR" # Load opencode-specific helpers when building for opencode @@ -190,7 +207,11 @@ mkdir -p "$VAULT_DIR/Meta/states" # ── Install components ──────────────────────────────────────────────────────── info "Installing agents..." -AGENT_COUNT=$(install_agents "$DIST_COMPONENTS_DIR/agents" "$VAULT_COMPONENTS_DIR/agents") +if [[ "$PLATFORM" == "codex-cli" ]]; then + AGENT_COUNT=$(install_toml_agents "$DIST_COMPONENTS_DIR/agents" "$VAULT_COMPONENTS_DIR/agents") +else + AGENT_COUNT=$(install_agents "$DIST_COMPONENTS_DIR/agents" "$VAULT_COMPONENTS_DIR/agents") +fi success "Agents: $AGENT_COUNT installed/updated" info "Installing references..." @@ -198,7 +219,7 @@ REF_COUNT=$(install_refs "$DIST_COMPONENTS_DIR/references" "$VAULT_COMPONENTS_DI success "References: $REF_COUNT installed/updated" info "Installing skills..." -SKILL_COUNT=$(install_skills "$DIST_COMPONENTS_DIR/skills" "$VAULT_COMPONENTS_DIR/skills") +SKILL_COUNT=$(install_skills "$DIST_SKILLS_DIR" "$VAULT_SKILLS_DIR") success "Skills: $SKILL_COUNT installed/updated" info "Installing hooks..." @@ -266,19 +287,30 @@ echo "" echo -e " ${VAULT_DIR}/" FW_DIR_NAME="$(basename "$VAULT_COMPONENTS_DIR")" DISPATCHER_NAME="$(basename "$DISPATCHER_DST")" -echo -e " ├── ${FW_DIR_NAME}/" -echo -e " │ ├── agents/ ${DIM}← agents${NC}" -echo -e " │ ├── skills/ ${DIM}← skills${NC}" -echo -e " │ ├── hooks/ ${DIM}← hooks${NC}" -if [[ $HAS_PLUGINS -eq 1 ]]; then - echo -e " │ ├── plugins/ ${DIM}← hook plugins${NC}" +if [[ "$PLATFORM" == "codex-cli" ]]; then + echo -e " ├── .codex/" + echo -e " │ ├── agents/ ${DIM}← custom agents${NC}" + echo -e " │ ├── references/ ${DIM}← shared docs${NC}" + echo -e " │ └── config.toml ${DIM}← MCP + profiles${NC}" + echo -e " ├── .agents/" + echo -e " │ └── skills/ ${DIM}← repo skills${NC}" else - echo -e " │ ├── settings.json ${DIM}← hooks configuration${NC}" + echo -e " ├── ${FW_DIR_NAME}/" + echo -e " │ ├── agents/ ${DIM}← agents${NC}" + echo -e " │ ├── skills/ ${DIM}← skills${NC}" + echo -e " │ ├── hooks/ ${DIM}← hooks${NC}" + if [[ $HAS_PLUGINS -eq 1 ]]; then + echo -e " │ ├── plugins/ ${DIM}← hook plugins${NC}" + else + echo -e " │ ├── settings.json ${DIM}← hooks configuration${NC}" + fi + echo -e " │ └── references/ ${DIM}← shared docs${NC}" fi -echo -e " │ └── references/ ${DIM}← shared docs${NC}" echo -e " ├── Meta/" echo -e " │ └── scripts/ ${DIM}← ${ORCH_COUNT:-0} orchestra scripts${NC}" -if [[ -n "$MCP_SRC" && -f "$MCP_DST" ]]; then +if [[ "$PLATFORM" == "codex-cli" && -f "$MCP_DST" ]]; then + echo -e " └── ${DISPATCHER_NAME} ${DIM}← project instructions${NC}" +elif [[ -n "$MCP_SRC" && -f "$MCP_DST" ]]; then echo -e " ├── ${DISPATCHER_NAME} ${DIM}← project instructions${NC}" echo -e " └── $(basename "$MCP_DST") ${DIM}← MCP servers${NC}" else @@ -295,6 +327,7 @@ case "$PLATFORM" in claude-code) echo -e " 1. Open Claude Code in your vault folder" ;; opencode) echo -e " 1. Open OpenCode in your vault folder" ;; gemini-cli) echo -e " 1. Open Gemini CLI in your vault folder" ;; + codex-cli) echo -e " 1. Open Codex CLI in your vault folder (run: codex)" ;; *) echo -e " 1. Open your agent platform in your vault folder" ;; esac echo -e " 2. Say: ${BOLD}\"Initialize my vault\"${NC}" diff --git a/scripts/lib.sh b/scripts/lib.sh index 50c8c26..a8c1d35 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -51,6 +51,20 @@ copy_if_changed() { fi } +# ── copy_tree_if_changed ──────────────────────────────── +# Overlays a source directory tree onto the destination when files differ or +# the destination does not exist. This preserves nested skill assets/scripts +# needed by platforms such as Codex CLI. +copy_tree_if_changed() { + local src="$1" dst="$2" + _LAST_CHANGED=0 + if [[ ! -d "$dst" ]] || ! diff -qr "$src" "$dst" >/dev/null 2>&1; then + mkdir -p "$dst" + cp -R "$src/." "$dst/" + _LAST_CHANGED=1 + fi +} + # ── _insert_after_start_marker ─────────────────────────────── # Inserts immediately after the MBIFC:CUSTOM_AGENTS_START line in dst. # dst must already exist and contain the marker. @@ -304,7 +318,32 @@ install_agents() { local src_dir="$1" dst_dir="$2" local count=0 manifest=() mkdir -p "$dst_dir" - for src in "$src_dir/"*.md; do + for src in "$src_dir/"*; do + [[ -f "$src" ]] || continue + case "$src" in + *.md|*.toml) ;; + *) continue ;; + esac + local name; name="$(basename "$src")" + manifest+=("$name") + copy_if_changed "$src" "$dst_dir/$name" + if [[ $_LAST_CHANGED -eq 1 ]]; then + [[ $VERBOSE_COPY -eq 1 ]] && info "Updated agent: $name" || true + count=$((count + 1)) + fi + done + manifest_write "agents" "${manifest[@]}" + printf '%d' "$count" +} + +# install_toml_agents +# Copies *.toml agent files from src to dst. Used by Codex CLI platform installs +# where agents are .toml rather than .md. Tracked in the manifest under "agents". +install_toml_agents() { + local src_dir="$1" dst_dir="$2" + local count=0 manifest=() + mkdir -p "$dst_dir" + for src in "$src_dir/"*.toml; do [[ -f "$src" ]] || continue local name; name="$(basename "$src")" manifest+=("$name") @@ -349,8 +388,7 @@ install_skills() { [[ -f "${skill_src}SKILL.md" ]] || continue local name; name="$(basename "$skill_src")" manifest+=("$name") - mkdir -p "$dst_dir/$name" - copy_if_changed "${skill_src}SKILL.md" "$dst_dir/$name/SKILL.md" + copy_tree_if_changed "$skill_src" "$dst_dir/$name" if [[ $_LAST_CHANGED -eq 1 ]]; then [[ $VERBOSE_COPY -eq 1 ]] && info "Updated skill: $name" || true count=$((count + 1)) diff --git a/scripts/updateme.sh b/scripts/updateme.sh index 2ae2ce0..f6a6881 100755 --- a/scripts/updateme.sh +++ b/scripts/updateme.sh @@ -42,6 +42,7 @@ if [[ -z "$PLATFORM" ]]; then [[ -d "$VAULT_DIR/.claude/agents" ]] && DETECTED+=("claude-code") [[ -d "$VAULT_DIR/.opencode/agents" ]] && DETECTED+=("opencode") [[ -d "$VAULT_DIR/.gemini/agents" ]] && DETECTED+=("gemini-cli") + [[ -d "$VAULT_DIR/.codex/agents" ]] && DETECTED+=("codex-cli") if [[ ${#DETECTED[@]} -eq 0 ]]; then die "No installed platform detected in $VAULT_DIR — run launchme.sh first" @@ -73,6 +74,7 @@ case "$PLATFORM" in claude-code) _SETUP_CHECK="$VAULT_DIR/.claude/agents" ;; opencode) _SETUP_CHECK="$VAULT_DIR/.opencode/agents" ;; gemini-cli) _SETUP_CHECK="$VAULT_DIR/.gemini/agents" ;; + codex-cli) _SETUP_CHECK="$VAULT_DIR/.codex/agents" ;; *) die "Unknown platform: $PLATFORM" ;; esac [[ -d "$_SETUP_CHECK" ]] \ @@ -83,6 +85,7 @@ case "$PLATFORM" in opencode) _DISP_NAME="AGENTS.md"; _FW_DIR_NAME="opencode" ;; gemini-cli) _DISP_NAME="GEMINI.md"; _FW_DIR_NAME="gemini" ;; claude-code) _DISP_NAME="CLAUDE.md"; _FW_DIR_NAME="claude" ;; + codex-cli) _DISP_NAME="AGENTS.md"; _FW_DIR_NAME="codex" ;; *) die "Unknown platform: $PLATFORM" ;; esac echo -e "${BOLD}This will update core agents, skills, references, hooks, and ${_DISP_NAME}.${NC}" @@ -132,11 +135,26 @@ case "$PLATFORM" in MCP_SRC="" MCP_DST="" HAS_PLUGINS=0 + DIST_SKILLS_DIR="$DIST_COMPONENTS_DIR/skills" + VAULT_SKILLS_DIR="$VAULT_COMPONENTS_DIR/skills" + ;; + codex-cli) + DIST_COMPONENTS_DIR="$DIST_DIR/.codex" + VAULT_COMPONENTS_DIR="$VAULT_DIR/.codex" + DISPATCHER_SRC="$DIST_DIR/AGENTS.md" + DISPATCHER_DST="$VAULT_DIR/AGENTS.md" + MCP_SRC="$DIST_DIR/.codex/config.toml" + MCP_DST="$VAULT_DIR/.codex/config.toml" + HAS_PLUGINS=0 + DIST_SKILLS_DIR="$DIST_DIR/.agents/skills" + VAULT_SKILLS_DIR="$VAULT_DIR/.agents/skills" ;; *) die "Unknown platform: $PLATFORM (install layout not defined)" ;; esac +[[ -n "${DIST_SKILLS_DIR:-}" ]] || DIST_SKILLS_DIR="$DIST_COMPONENTS_DIR/skills" +[[ -n "${VAULT_SKILLS_DIR:-}" ]] || VAULT_SKILLS_DIR="$VAULT_COMPONENTS_DIR/skills" PLATFORM_VAULT_DIR="$VAULT_COMPONENTS_DIR" # Load opencode-specific helpers when building for opencode @@ -158,9 +176,13 @@ mkdir -p "$VAULT_DIR/Meta/states" # ── Update components (per-file logging enabled) ───────────────────────────── VERBOSE_COPY=1 -AGENT_COUNT=$(install_agents "$DIST_COMPONENTS_DIR/agents" "$VAULT_COMPONENTS_DIR/agents") +if [[ "$PLATFORM" == "codex-cli" ]]; then + AGENT_COUNT=$(install_toml_agents "$DIST_COMPONENTS_DIR/agents" "$VAULT_COMPONENTS_DIR/agents") +else + AGENT_COUNT=$(install_agents "$DIST_COMPONENTS_DIR/agents" "$VAULT_COMPONENTS_DIR/agents") +fi REF_COUNT=$(install_refs "$DIST_COMPONENTS_DIR/references" "$VAULT_COMPONENTS_DIR/references") -SKILL_COUNT=$(install_skills "$DIST_COMPONENTS_DIR/skills" "$VAULT_COMPONENTS_DIR/skills") +SKILL_COUNT=$(install_skills "$DIST_SKILLS_DIR" "$VAULT_SKILLS_DIR") HOOK_COUNT=$(install_hooks "$DIST_COMPONENTS_DIR/hooks" "$VAULT_COMPONENTS_DIR/hooks") PLUGIN_COUNT=0 diff --git a/tests/adapters/codex-cli/adapter.test.sh b/tests/adapters/codex-cli/adapter.test.sh new file mode 100644 index 0000000..32a7c48 --- /dev/null +++ b/tests/adapters/codex-cli/adapter.test.sh @@ -0,0 +1,1458 @@ +#!/usr/bin/env bash +# Tests for adapters/codex-cli/adapter.sh +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +source "$ROOT/adapters/lib.sh" +source "$ROOT/adapters/codex-cli/adapter.sh" + +# --------------------------------------------------------------------------- +# Helper: resolve python interpreter (python or python3) +# Verifies the command actually works (not a Windows Store redirect stub). +# --------------------------------------------------------------------------- +_python_cmd() { + for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1; then + # Verify the candidate actually runs (Windows may have stub redirects) + if "$candidate" -c "import sys; sys.exit(0)" >/dev/null 2>&1; then + echo "$candidate" + return 0 + fi + fi + done + echo "" +} + +# --------------------------------------------------------------------------- +# Dispatcher tests +# --------------------------------------------------------------------------- + +test_cc_translate_dispatcher_renames_to_agents_md() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + cat > "$src/DISPATCHER.md" <<'EOF' +# Dispatcher +See .platform/agents/ for agents. Consult DISPATCHER.md for rules. +EOF + adapter_translate_dispatcher "$src/DISPATCHER.md" "$dst" + local result=0 + [[ -f "$dst/AGENTS.md" ]] || { echo "AGENTS.md not created"; result=1; } + [[ ! -f "$dst/CLAUDE.md" ]] || { echo "CLAUDE.md should not exist"; result=1; } + local content; content="$(cat "$dst/AGENTS.md")" + [[ "$content" == *".codex/agents/"* ]] || { echo ".codex/agents/ not found: $content"; result=1; } + [[ "$content" == *"AGENTS.md"* ]] || { echo "AGENTS.md ref not rewritten: $content"; result=1; } + [[ "$content" != *".platform/"* ]] || { echo ".platform/ still present: $content"; result=1; } + [[ "$content" != *"DISPATCHER.md"* ]] || { echo "DISPATCHER.md still present: $content"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_dispatcher_missing_src_is_noop() { + local dst; dst="$(mktemp -d)" + # Should not error even when source file doesn't exist + adapter_translate_dispatcher "/nonexistent/DISPATCHER.md" "$dst" + local result=0 + [[ ! -f "$dst/AGENTS.md" ]] || { echo "AGENTS.md should not be created"; result=1; } + rm -rf "$dst" + return $result +} + +# --------------------------------------------------------------------------- +# References tests +# --------------------------------------------------------------------------- + +test_cc_translate_references_copies_to_codex_dir() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/references" + echo "ref1" > "$src/references/one.md" + echo "ref2" > "$src/references/two.md" + adapter_translate_references "$src/references" "$dst" + local result=0 + [[ -f "$dst/.codex/references/one.md" ]] || { echo "one.md missing"; result=1; } + [[ -f "$dst/.codex/references/two.md" ]] || { echo "two.md missing"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_references_rewrites_paths() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/references" + printf 'See .platform/agents/ and DISPATCHER.md for details.\n' > "$src/references/guide.md" + adapter_translate_references "$src/references" "$dst" + local content; content="$(cat "$dst/.codex/references/guide.md")" + local result=0 + [[ "$content" == *".codex/agents/"* ]] || { echo ".codex/agents/ not found: $content"; result=1; } + [[ "$content" == *"AGENTS.md"* ]] || { echo "AGENTS.md not found: $content"; result=1; } + [[ "$content" != *".platform/"* ]] || { echo ".platform/ still present: $content"; result=1; } + [[ "$content" != *"DISPATCHER.md"* ]] || { echo "DISPATCHER.md still present: $content"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_references_honors_exclude() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/references" + cat > "$src/references/hidden.md" <<'EOF' +--- +exclude: [codex-cli] +--- +secret +EOF + adapter_translate_references "$src/references" "$dst" + local result=0 + [[ ! -f "$dst/.codex/references/hidden.md" ]] || { echo "hidden.md should be excluded"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +# --------------------------------------------------------------------------- +# Skills tests +# --------------------------------------------------------------------------- + +test_cc_translate_skills_copies_to_agents_skills_dir() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/skills/foo" "$src/skills/bar" + cat > "$src/skills/foo/SKILL.md" <<'SKILLEOF' +--- +name: foo +description: Foo skill +--- +body +SKILLEOF + cat > "$src/skills/bar/SKILL.md" <<'SKILLEOF' +--- +name: bar +description: Bar skill +--- +body +SKILLEOF + adapter_translate_skills "$src/skills" "$dst" + local result=0 + [[ -f "$dst/.agents/skills/foo/SKILL.md" ]] || { echo "foo missing"; result=1; } + [[ -f "$dst/.agents/skills/bar/SKILL.md" ]] || { echo "bar missing"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_skills_rewrites_paths() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/skills/create-agent" + cat > "$src/skills/create-agent/SKILL.md" <<'SKILLEOF' +--- +name: create-agent +description: Create a new agent +--- +Save to .platform/agents/ and update DISPATCHER.md. +SKILLEOF + adapter_translate_skills "$src/skills" "$dst" + local content; content="$(cat "$dst/.agents/skills/create-agent/SKILL.md")" + local result=0 + [[ "$content" == *".codex/agents/"* ]] || { echo ".codex/agents/ not found: $content"; result=1; } + [[ "$content" == *"AGENTS.md"* ]] || { echo "AGENTS.md not found: $content"; result=1; } + [[ "$content" != *".platform/"* ]] || { echo ".platform/ still present: $content"; result=1; } + [[ "$content" != *"DISPATCHER.md"* ]] || { echo "DISPATCHER.md still present: $content"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_skills_honors_exclude() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/skills/secret" + cat > "$src/skills/secret/SKILL.md" <<'SKILLEOF' +--- +name: secret +description: Hidden skill +exclude: [codex-cli] +--- +SKILLEOF + adapter_translate_skills "$src/skills" "$dst" + local result=0 + [[ ! -f "$dst/.agents/skills/secret/SKILL.md" ]] || { echo "secret should be excluded"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_skills_real_corpus_has_exact_skill_directories() { + local dst; dst="$(mktemp -d)" + adapter_translate_skills "$ROOT/skills" "$dst" + + local result=0 + local expected=( + contact-sync + create-agent + deadline-radar + deep-clean + defrag + email-triage + inbox-triage + manage-agent + meeting-prep + onboarding + tag-garden + transcribe + vault-audit + weekly-agenda + ) + + mapfile -t actual < <(find "$dst/.agents/skills" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort) + [[ "${#actual[@]}" -eq 14 ]] \ + || { echo "expected 14 generated skill directories, found ${#actual[@]}: ${actual[*]}"; result=1; } + + local idx + for idx in "${!expected[@]}"; do + [[ "${actual[$idx]:-missing}" == "${expected[$idx]}" ]] \ + || { echo "expected skill ${expected[$idx]} at index $idx, found ${actual[$idx]:-missing}"; result=1; } + [[ -f "$dst/.agents/skills/${expected[$idx]}/SKILL.md" ]] \ + || { echo "missing SKILL.md for ${expected[$idx]}"; result=1; } + done + + rm -rf "$dst" + return $result +} + +test_cc_translate_skills_rewrites_high_risk_real_skills_for_codex() { + local dst; dst="$(mktemp -d)" + adapter_translate_skills "$ROOT/skills" "$dst" + + local result=0 + local onboarding="$dst/.agents/skills/onboarding/SKILL.md" + local create_agent="$dst/.agents/skills/create-agent/SKILL.md" + local manage_agent="$dst/.agents/skills/manage-agent/SKILL.md" + local transcribe="$dst/.agents/skills/transcribe/SKILL.md" + + local file content + for file in "$onboarding" "$create_agent" "$manage_agent" "$transcribe"; do + [[ -f "$file" ]] || { echo "missing generated high-risk skill: $file"; result=1; continue; } + content="$(cat "$file")" + [[ "$content" != *'AskUserQuestion'* ]] || { echo "AskUserQuestion leaked into $(basename "$(dirname "$file")")"; result=1; } + [[ "$content" != *'request_user_input'* ]] || { echo "request_user_input leaked into $(basename "$(dirname "$file")")"; result=1; } + [[ "$content" != *'.platform/'* ]] || { echo ".platform/ leaked into $(basename "$(dirname "$file")")"; result=1; } + done + + grep -qi '\.codex/config\.toml' "$onboarding" \ + || { echo 'onboarding skill should reference .codex/config.toml'; result=1; } + [[ "$(cat "$onboarding")" != *'.mcp.json'* ]] \ + || { echo 'onboarding skill should not mention .mcp.json'; result=1; } + grep -q '\.codex/agents/{name}\.toml' "$create_agent" \ + || { echo 'create-agent skill should target .codex/agents/{name}.toml'; result=1; } + grep -q '\.codex/agents/{name}\.toml' "$manage_agent" \ + || { echo 'manage-agent skill should target .codex/agents/{name}.toml'; result=1; } + grep -qi 'ask one direct plain-text question' "$create_agent" \ + || { echo 'create-agent skill should use direct-chat question wording'; result=1; } + grep -qi 'wait for the user.s reply before continuing' "$create_agent" \ + || { echo 'create-agent skill should tell Codex to wait for the user reply'; result=1; } + grep -qi 'resume from the saved state file if the flow is already active' "$manage_agent" \ + || { echo 'manage-agent skill should mention saved-state resumption'; result=1; } + grep -qi 'ask one direct plain-text question' "$transcribe" \ + || { echo 'transcribe skill should use direct-chat question wording'; result=1; } + grep -qi 'wait for the user.s reply before continuing' "$transcribe" \ + || { echo 'transcribe skill should tell Codex to wait for the user reply'; result=1; } + + rm -rf "$dst" + return $result +} + +test_cc_translate_skills_preserves_optional_directories_and_rewrites_text_files() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/skills/fixture/scripts" \ + "$src/skills/fixture/references" \ + "$src/skills/fixture/assets" \ + "$src/skills/fixture/agents" + + cat > "$src/skills/fixture/SKILL.md" <<'EOF' +--- +name: fixture +description: Fixture skill +--- +Use .platform/skills/fixture/, .platform/agents/example.md, and DISPATCHER.md. +EOF + + cat > "$src/skills/fixture/scripts/run.sh" <<'EOF' +#!/usr/bin/env bash +echo fixture +EOF + + cat > "$src/skills/fixture/references/guide.md" <<'EOF' +See .platform/references/guide.md and .platform/skills/fixture/. +EOF + + cat > "$src/skills/fixture/assets/template.md" <<'EOF' +Copy from .platform/agents/example.md and DISPATCHER.md. +EOF + + cat > "$src/skills/fixture/agents/openai.yaml" <<'EOF' +instruction: "Check .platform/references/guide.md before touching .platform/skills/fixture/" +EOF + + adapter_translate_skills "$src/skills" "$dst" + + local result=0 + [[ -f "$dst/.agents/skills/fixture/SKILL.md" ]] || { echo "fixture SKILL.md missing"; result=1; } + [[ -f "$dst/.agents/skills/fixture/scripts/run.sh" ]] || { echo "fixture script missing"; result=1; } + [[ -f "$dst/.agents/skills/fixture/references/guide.md" ]] || { echo "fixture reference missing"; result=1; } + [[ -f "$dst/.agents/skills/fixture/assets/template.md" ]] || { echo "fixture asset missing"; result=1; } + [[ -f "$dst/.agents/skills/fixture/agents/openai.yaml" ]] || { echo "fixture agents/openai.yaml missing"; result=1; } + + local content + content="$(cat "$dst/.agents/skills/fixture/SKILL.md")" + [[ "$content" == *'.agents/skills/fixture/'* ]] || { echo "fixture SKILL.md should rewrite .platform/skills/"; result=1; } + [[ "$content" == *'.codex/agents/example.md'* ]] || { echo "fixture SKILL.md should rewrite .platform/agents/"; result=1; } + [[ "$content" == *'AGENTS.md'* ]] || { echo "fixture SKILL.md should rewrite DISPATCHER.md"; result=1; } + + content="$(cat "$dst/.agents/skills/fixture/references/guide.md")" + [[ "$content" == *'.codex/references/guide.md'* ]] || { echo "fixture reference should rewrite .platform/references/"; result=1; } + [[ "$content" == *'.agents/skills/fixture/'* ]] || { echo "fixture reference should rewrite .platform/skills/"; result=1; } + + content="$(cat "$dst/.agents/skills/fixture/assets/template.md")" + [[ "$content" == *'.codex/agents/example.md'* ]] || { echo "fixture asset should rewrite .platform/agents/"; result=1; } + [[ "$content" == *'AGENTS.md'* ]] || { echo "fixture asset should rewrite DISPATCHER.md"; result=1; } + + content="$(cat "$dst/.agents/skills/fixture/agents/openai.yaml")" + [[ "$content" == *'.codex/references/guide.md'* ]] || { echo "fixture openai.yaml should rewrite .platform/references/"; result=1; } + [[ "$content" == *'.agents/skills/fixture/'* ]] || { echo "fixture openai.yaml should rewrite .platform/skills/"; result=1; } + + rm -rf "$src" "$dst" + return $result +} + +# --------------------------------------------------------------------------- +# Config translation tests +# --------------------------------------------------------------------------- + +test_cc_translate_config_writes_codex_config_toml() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Gmail + type: http + url: "https://gmail.mcp.claude.com/mcp" + env: {} + exclude: [] +EOF + adapter_translate_config "$src/mcp" "$dst" + local result=0 + [[ -f "$dst/.codex/config.toml" ]] || { echo "config.toml missing"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_config_has_baseline_settings() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Gmail + type: http + url: "https://gmail.mcp.claude.com/mcp" + env: {} +EOF + adapter_translate_config "$src/mcp" "$dst" + local content; content="$(cat "$dst/.codex/config.toml")" + local result=0 + [[ "$content" == *'approval_policy = "on-request"'* ]] || { echo "approval_policy missing"; result=1; } + [[ "$content" == *'sandbox_mode = "workspace-write"'* ]] || { echo "sandbox_mode missing"; result=1; } + [[ "$content" == *'max_depth = 1'* ]] || { echo "agents.max_depth missing"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_config_gmail_http_server() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Gmail + type: http + url: "https://gmail.mcp.claude.com/mcp" + env: {} + exclude: [] +EOF + adapter_translate_config "$src/mcp" "$dst" + local content; content="$(cat "$dst/.codex/config.toml")" + local result=0 + [[ "$content" == *'[mcp_servers.Gmail]'* ]] || { echo "[mcp_servers.Gmail] missing: $content"; result=1; } + [[ "$content" == *'url = "https://gmail.mcp.claude.com/mcp"'* ]] || { echo "url missing"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_config_google_calendar_uses_quoted_table() { + # Server names with spaces must use quoted table names: [mcp_servers."Google Calendar"] + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Google Calendar + type: http + url: "https://gcal.mcp.claude.com/mcp" + env: {} + exclude: [] +EOF + adapter_translate_config "$src/mcp" "$dst" + local content; content="$(cat "$dst/.codex/config.toml")" + local result=0 + [[ "$content" == *'[mcp_servers."Google Calendar"]'* ]] || { echo 'quoted table [mcp_servers."Google Calendar"] missing'; echo "$content"; result=1; } + [[ "$content" == *'url = "https://gcal.mcp.claude.com/mcp"'* ]] || { echo "gcal url missing"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_config_both_servers_from_real_yaml() { + # Use the actual mcp/servers.yaml from the repo + local dst; dst="$(mktemp -d)" + adapter_translate_config "$ROOT/mcp" "$dst" + local content; content="$(cat "$dst/.codex/config.toml")" + local result=0 + [[ "$content" == *'[mcp_servers.Gmail]'* ]] || { echo "[mcp_servers.Gmail] missing"; result=1; } + [[ "$content" == *'[mcp_servers."Google Calendar"]'* ]] || { echo '[mcp_servers."Google Calendar"] missing'; result=1; } + rm -rf "$dst" + return $result +} + +test_cc_translate_config_toml_is_parseable() { + # Validate the generated config.toml passes the TOML smoke check + local py; py="$(_python_cmd)" + if [[ -z "$py" ]]; then + echo "SKIP: python not found" + return 0 + fi + + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Gmail + type: http + url: "https://gmail.mcp.claude.com/mcp" + env: {} + exclude: [] + - name: Google Calendar + type: http + url: "https://gcal.mcp.claude.com/mcp" + env: {} + exclude: [] +EOF + adapter_translate_config "$src/mcp" "$dst" + + local result=0 + if ! "$py" "$ROOT/tests/support/toml_smoke_check.py" "$dst/.codex/config.toml"; then + echo "TOML smoke check failed" + result=1 + fi + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_config_has_network_access_and_profiles() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Gmail + type: http + url: "https://gmail.mcp.claude.com/mcp" + env: {} +EOF + adapter_translate_config "$src/mcp" "$dst" + local content; content="$(cat "$dst/.codex/config.toml")" + local result=0 + [[ "$content" == *'sandbox_workspace_write.network_access = false'* ]] || { echo "network_access default missing"; result=1; } + [[ "$content" == *'[profiles.quality]'* ]] || { echo "profiles.quality missing"; result=1; } + [[ "$content" == *'model = "gpt-5.4"'* ]] || { echo "quality model missing"; result=1; } + [[ "$content" == *'model_reasoning_effort = "high"'* ]] || { echo "quality reasoning effort missing"; result=1; } + [[ "$content" == *'[profiles.balanced]'* ]] || { echo "profiles.balanced missing"; result=1; } + [[ "$content" == *'model = "gpt-5.4-mini"'* ]] || { echo "balanced model missing"; result=1; } + [[ "$content" == *'[profiles.budget]'* ]] || { echo "profiles.budget missing"; result=1; } + [[ "$content" == *'model = "gpt-5.3-codex-spark"'* ]] || { echo "budget model missing"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_config_preserves_nonempty_env_values() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Local Demo + type: local + command: ["npx", "-y", "@demo/server"] + env: {"API_KEY":"abc123","SPACE VALUE":"hello world"} +EOF + adapter_translate_config "$src/mcp" "$dst" + local content; content="$(cat "$dst/.codex/config.toml")" + local result=0 + [[ "$content" == *'[mcp_servers."Local Demo"]'* ]] || { echo 'Local Demo server table missing'; result=1; } + [[ "$content" == *'command = ["npx", "-y", "@demo/server"]'* ]] || { echo 'command array missing'; result=1; } + [[ "$content" == *'env = {'* ]] || { echo 'env inline table missing'; result=1; } + [[ "$content" == *'"API_KEY" = "abc123"'* || "$content" == *'API_KEY = "abc123"'* ]] || { echo 'API_KEY env missing'; result=1; } + [[ "$content" == *'"SPACE VALUE" = "hello world"'* ]] || { echo 'SPACE VALUE env missing'; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_rewrite_codex_paths_targets_each_platform_path() { + local tmp; tmp="$(mktemp)" + cat > "$tmp" <<'EOF' +See .platform/agents/alpha.md, .platform/references/guide.md, and .platform/skills/onboarding/SKILL.md. +Fallback: .platform/config.toml. Also consult DISPATCHER.md. +EOF + rewrite_codex_paths "$tmp" + local content; content="$(cat "$tmp")" + local result=0 + [[ "$content" == *'.codex/agents/alpha.md'* ]] \ + || { echo ".platform/agents/ not rewritten correctly: $content"; result=1; } + [[ "$content" == *'.codex/references/guide.md'* ]] \ + || { echo ".platform/references/ not rewritten correctly: $content"; result=1; } + [[ "$content" == *'.agents/skills/onboarding/SKILL.md'* ]] \ + || { echo ".platform/skills/ not rewritten correctly: $content"; result=1; } + [[ "$content" == *'.codex/config.toml'* ]] \ + || { echo "remaining .platform/ path not rewritten to .codex/: $content"; result=1; } + [[ "$content" == *'AGENTS.md'* ]] \ + || { echo "DISPATCHER.md not rewritten to AGENTS.md: $content"; result=1; } + [[ "$content" != *'.platform/'* ]] \ + || { echo ".platform/ still present after targeted rewrite: $content"; result=1; } + [[ "$content" != *'DISPATCHER.md'* ]] \ + || { echo "DISPATCHER.md still present after targeted rewrite: $content"; result=1; } + rm -f "$tmp" + return $result +} + +test_toml_smoke_check_rejects_invalid_backslash_in_quoted_key() { + # Quoted table keys must reject invalid TOML backslash escape sequences. + local py; py="$(_python_cmd)" + if [[ -z "$py" ]]; then + echo "SKIP: python not found" + return 0 + fi + + local tmp; tmp="$(mktemp)" + cat > "$tmp" <<'EOF' +[mcp_servers."foo\bar"] +url = "https://example.com" +EOF + + local result=0 + if "$py" "$ROOT/tests/support/toml_smoke_check.py" "$tmp" >/dev/null 2>&1; then + echo "smoke check should reject invalid quoted key backslash escapes" + result=1 + fi + + rm -f "$tmp" + return $result +} + +test_cc_translate_config_name_with_backslash_is_escaped_and_parseable() { + # A server name containing \ must produce a valid TOML quoted key. + local py; py="$(_python_cmd)" + if [[ -z "$py" ]]; then + echo "SKIP: python not found" + return 0 + fi + + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: "foo\bar" + type: http + url: "https://example.com" +EOF + + adapter_translate_config "$src/mcp" "$dst" + + local content; content="$(cat "$dst/.codex/config.toml")" + local result=0 + [[ "$content" == *'[mcp_servers."foo\\bar"]'* ]] \ + || { echo "escaped backslash key missing: $content"; result=1; } + if ! "$py" "$ROOT/tests/support/toml_smoke_check.py" "$dst/.codex/config.toml"; then + echo "smoke check failed for escaped backslash key" + result=1 + fi + + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_config_local_server() { + # Local (command-based) MCP server translates to TOML command array + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: local-tool + type: local + command: [npx, -y, "@anthropic-ai/mcp-tool"] + env: {} +EOF + adapter_translate_config "$src/mcp" "$dst" + local content; content="$(cat "$dst/.codex/config.toml")" + local result=0 + [[ "$content" == *'[mcp_servers.local-tool]'* ]] || { echo "[mcp_servers.local-tool] missing: $content"; result=1; } + [[ "$content" == *'command = ['* ]] || { echo "command array missing"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_config_multiline_env_keys_do_not_override_server_type() { + # Nested env mappings must not be mistaken for top-level server fields. + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: local-tool + type: local + command: [npx, -y, "@anthropic-ai/mcp-tool"] + env: + deployment_type: "prod" +EOF + + adapter_translate_config "$src/mcp" "$dst" + + local content; content="$(cat "$dst/.codex/config.toml")" + local result=0 + [[ "$content" == *'command = ["npx", "-y", "@anthropic-ai/mcp-tool"]'* ]] \ + || { echo "command array missing or corrupted: $content"; result=1; } + [[ "$content" != *'url = ""'* ]] \ + || { echo "env child key incorrectly overrode server type: $content"; result=1; } + + rm -rf "$src" "$dst" + return $result +} + +# --------------------------------------------------------------------------- +# End-to-end adapter_build test +# --------------------------------------------------------------------------- + +test_cc_adapter_build_end_to_end() { + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + + # Minimal source tree + echo "# Dispatcher - check .platform/agents/ and DISPATCHER.md" > "$src/DISPATCHER.md" + mkdir -p "$src/references" "$src/skills/onboarding" "$src/mcp" + + cat > "$src/references/policy.md" <<'EOF' +--- +name: policy +--- +Policy reference content. +EOF + + cat > "$src/skills/onboarding/SKILL.md" <<'SKILLEOF' +--- +name: onboarding +description: Onboarding skill +--- +Skill body - see .platform/agents/ for agents. +SKILLEOF + + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Gmail + type: http + url: "https://gmail.mcp.claude.com/mcp" + env: {} + exclude: [] + - name: Google Calendar + type: http + url: "https://gcal.mcp.claude.com/mcp" + env: {} + exclude: [] +EOF + + adapter_build "$src" "$dst" + + local result=0 + + # Check dispatcher + [[ -f "$dst/AGENTS.md" ]] || { echo "AGENTS.md missing"; result=1; } + + # Check references + [[ -f "$dst/.codex/references/policy.md" ]] || { echo "policy.md reference missing"; result=1; } + + # Check skills in .agents/skills/ (Codex CLI uses .agents/ not .codex/) + [[ -f "$dst/.agents/skills/onboarding/SKILL.md" ]] || { echo "onboarding SKILL.md missing"; result=1; } + + # Check config.toml exists and has expected tables + [[ -f "$dst/.codex/config.toml" ]] || { echo "config.toml missing"; result=1; } + local cfg; cfg="$(cat "$dst/.codex/config.toml" 2>/dev/null)" + [[ "$cfg" == *'[mcp_servers.Gmail]'* ]] || { echo "[mcp_servers.Gmail] missing in config.toml"; result=1; } + [[ "$cfg" == *'[mcp_servers."Google Calendar"]'* ]] || { echo '[mcp_servers."Google Calendar"] missing'; result=1; } + + # Verify path rewrites in dispatcher + local disp; disp="$(cat "$dst/AGENTS.md")" + [[ "$disp" == *".codex/agents/"* ]] || { echo ".codex/agents/ rewrite missing in AGENTS.md"; result=1; } + [[ "$disp" != *".platform/"* ]] || { echo ".platform/ still present in AGENTS.md"; result=1; } + + rm -rf "$src" "$dst" + return $result +} + +test_cc_adapter_build_overwrites_existing_dst() { + # adapter_build should wipe and recreate the output dir + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + + echo "# Dispatcher" > "$src/DISPATCHER.md" + mkdir -p "$src/mcp" + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Gmail + type: http + url: "https://gmail.mcp.claude.com/mcp" + env: {} +EOF + + # Pre-populate dst with a stale file + mkdir -p "$dst/.codex" + echo "stale content" > "$dst/.codex/stale.toml" + + adapter_build "$src" "$dst" + + local result=0 + [[ ! -f "$dst/.codex/stale.toml" ]] || { echo "stale.toml should have been removed"; result=1; } + [[ -f "$dst/AGENTS.md" ]] || { echo "AGENTS.md missing after rebuild"; result=1; } + + rm -rf "$src" "$dst" + return $result +} + +# --------------------------------------------------------------------------- +# TOML agent translation unit tests +# --------------------------------------------------------------------------- + +test_cc_translate_agent_toml_required_keys() { + # A minimal agent with folded-block description should produce all required TOML keys + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/agents" + cat > "$src/agents/transcriber.md" <<'AGENTEOF' +--- +name: transcriber +description: > + Process audio recordings and voice + memos into structured notes. +mode: subagent +capabilities: [read, write] +model: mid +--- + +# Transcriber + +Handle audio files. See .platform/agents/ and DISPATCHER.md for routing. +AGENTEOF + + adapter_translate_agent_toml "$src/agents/transcriber.md" "$dst" + + local result=0 + local toml_file="$dst/.codex/agents/transcriber.toml" + [[ -f "$toml_file" ]] || { echo "transcriber.toml not created at $toml_file"; result=1; return $result; } + + local content; content="$(cat "$toml_file")" + # Required keys + [[ "$content" == *'name = "transcriber"'* ]] || { echo 'name key missing or wrong'; echo "$content"; result=1; } + [[ "$content" == *'description = "'* ]] || { echo 'description key missing'; echo "$content"; result=1; } + [[ "$content" == *'developer_instructions'* ]] || { echo 'developer_instructions key missing'; echo "$content"; result=1; } + # Path rewrites applied + [[ "$content" != *".platform/"* ]] || { echo '.platform/ still present (path rewrite failed)'; echo "$content"; result=1; } + [[ "$content" != *"DISPATCHER.md"* ]] || { echo 'DISPATCHER.md still present (path rewrite failed)'; echo "$content"; result=1; } + # Body content present + [[ "$content" == *".codex/agents/"* ]] || { echo '.codex/agents/ not found in body'; echo "$content"; result=1; } + [[ "$content" == *"AGENTS.md"* ]] || { echo 'AGENTS.md not found in body'; echo "$content"; result=1; } + + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_agent_toml_description_folded_block() { + # Folded-block description (description: >) must be collapsed to single line + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/agents" + cat > "$src/agents/myagent.md" <<'AGENTEOF' +--- +name: myagent +description: > + First line of description that continues + on the second line and the third line. +mode: subagent +capabilities: [read] +model: low +--- + +Body text here. +AGENTEOF + + adapter_translate_agent_toml "$src/agents/myagent.md" "$dst" + + local result=0 + local toml_file="$dst/.codex/agents/myagent.toml" + [[ -f "$toml_file" ]] || { echo "myagent.toml not created"; result=1; return $result; } + + local content; content="$(cat "$toml_file")" + # description must be a single quoted line (not multiline) + local desc_line; desc_line="$(grep '^description = ' "$toml_file")" + [[ -n "$desc_line" ]] || { echo 'description line missing'; result=1; } + # The description value should contain words from all continuation lines + [[ "$desc_line" == *"First line"* ]] || { echo "First line missing from description"; echo "$desc_line"; result=1; } + [[ "$desc_line" == *"second line"* ]] || { echo "second line missing from description"; echo "$desc_line"; result=1; } + [[ "$desc_line" == *"third line"* ]] || { echo "third line missing from description"; echo "$desc_line"; result=1; } + + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_agent_toml_no_platform_paths_in_output() { + # .platform/ and DISPATCHER.md must not appear in the generated TOML + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/agents" + cat > "$src/agents/sorter.md" <<'AGENTEOF' +--- +name: sorter +description: Sort inbox items. +mode: subagent +capabilities: [read, write] +model: low +--- + +Read .platform/references/routing.md and consult DISPATCHER.md for rules. +AGENTEOF + + adapter_translate_agent_toml "$src/agents/sorter.md" "$dst" + + local result=0 + local toml_file="$dst/.codex/agents/sorter.toml" + [[ -f "$toml_file" ]] || { echo "sorter.toml not created"; result=1; return $result; } + + local content; content="$(cat "$toml_file")" + [[ "$content" != *".platform/"* ]] || { echo '.platform/ found — path rewrite failed'; result=1; } + [[ "$content" != *"DISPATCHER.md"* ]] || { echo 'DISPATCHER.md found — path rewrite failed'; result=1; } + [[ "$content" == *".codex/"* ]] || { echo '.codex/ not found — path rewrite not applied'; result=1; } + [[ "$content" == *"AGENTS.md"* ]] || { echo 'AGENTS.md not found — path rewrite not applied'; result=1; } + + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_agent_toml_multiline_literal_string_format() { + # developer_instructions should use TOML multiline literal string (''') + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/agents" + cat > "$src/agents/simple.md" <<'AGENTEOF' +--- +name: simple +description: A simple agent. +mode: subagent +capabilities: [read] +model: low +--- + +Do something simple. +AGENTEOF + + adapter_translate_agent_toml "$src/agents/simple.md" "$dst" + + local result=0 + local toml_file="$dst/.codex/agents/simple.toml" + [[ -f "$toml_file" ]] || { echo "simple.toml not created"; result=1; return $result; } + + local content; content="$(cat "$toml_file")" + # Must use multiline literal (''') or basic (""") string for developer_instructions + [[ "$content" == *"developer_instructions = '''"* || "$content" == *'developer_instructions = """'* ]] \ + || { echo "developer_instructions is not a multiline string"; echo "$content"; result=1; } + + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_agent_toml_body_embedded_in_developer_instructions() { + # The body (after frontmatter) must be embedded in developer_instructions + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/agents" + cat > "$src/agents/checker.md" <<'AGENTEOF' +--- +name: checker +description: Check things. +mode: subagent +capabilities: [read] +model: low +--- + +## My Section + +This is the agent body content that must appear in developer_instructions. +AGENTEOF + + adapter_translate_agent_toml "$src/agents/checker.md" "$dst" + + local result=0 + local toml_file="$dst/.codex/agents/checker.toml" + [[ -f "$toml_file" ]] || { echo "checker.toml not created"; result=1; return $result; } + + local content; content="$(cat "$toml_file")" + [[ "$content" == *"My Section"* ]] || { echo 'body section header missing'; result=1; } + [[ "$content" == *"agent body content"* ]] || { echo 'body text missing'; result=1; } + + rm -rf "$src" "$dst" + return $result +} + +# --------------------------------------------------------------------------- +# End-to-end adapter_build test for TOML agent output +# --------------------------------------------------------------------------- + +test_cc_adapter_build_generates_transcriber_toml() { + # adapter_build should produce .codex/agents/transcriber.toml from source agents/ + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + + mkdir -p "$src/agents" "$src/mcp" + cat > "$src/DISPATCHER.md" <<'EOF' +# Dispatcher — see .platform/agents/ and DISPATCHER.md +EOF + + cat > "$src/agents/transcriber.md" <<'AGENTEOF' +--- +name: transcriber +description: > + Process audio recordings, raw transcriptions, podcasts, lectures, interviews, and voice + memos into structured Obsidian notes. +mode: subagent +capabilities: [read, write] +model: mid +--- + +# Transcriber + +Handle .platform/references/ and see DISPATCHER.md for routing. +AGENTEOF + + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Gmail + type: http + url: "https://gmail.mcp.claude.com/mcp" + env: {} +EOF + + adapter_build "$src" "$dst" + + local result=0 + local toml_file="$dst/.codex/agents/transcriber.toml" + [[ -f "$toml_file" ]] || { echo ".codex/agents/transcriber.toml not generated by adapter_build"; result=1; return $result; } + + local content; content="$(cat "$toml_file")" + [[ "$content" == *'name = "transcriber"'* ]] || { echo 'name key missing'; echo "$content"; result=1; } + [[ "$content" == *'description = "'* ]] || { echo 'description key missing'; echo "$content"; result=1; } + [[ "$content" == *'developer_instructions'* ]] || { echo 'developer_instructions key missing'; echo "$content"; result=1; } + [[ "$content" != *".platform/"* ]] || { echo '.platform/ still present'; echo "$content"; result=1; } + + rm -rf "$src" "$dst" + return $result +} + +test_cc_translate_agent_toml_is_parseable_by_smoke_check() { + # The generated agent TOML must pass the TOML smoke checker + local py; py="$(_python_cmd)" + if [[ -z "$py" ]]; then + echo "SKIP: python not found" + return 0 + fi + + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/agents" + # Use the real transcriber agent as the most demanding test case + cp "$ROOT/agents/transcriber.md" "$src/agents/transcriber.md" + + adapter_translate_agent_toml "$src/agents/transcriber.md" "$dst" + + local result=0 + local toml_file="$dst/.codex/agents/transcriber.toml" + [[ -f "$toml_file" ]] || { echo "transcriber.toml not generated"; result=1; return $result; } + + if ! "$py" "$ROOT/tests/support/toml_smoke_check.py" "$toml_file"; then + echo "TOML smoke check failed for agent TOML" + result=1 + fi + + rm -rf "$src" "$dst" + return $result +} + +test_cc_adapter_build_real_agent_corpus_has_exact_toml_files() { + local dst; dst="$(mktemp -d)" + adapter_build "$ROOT" "$dst" + + local result=0 + local expected=( + architect.toml + connector.toml + librarian.toml + postman.toml + scribe.toml + seeker.toml + sorter.toml + transcriber.toml + ) + + mapfile -t actual < <(find "$dst/.codex/agents" -maxdepth 1 -name '*.toml' -printf '%f\n' | sort) + [[ "${#actual[@]}" -eq 8 ]] \ + || { echo "expected 8 generated agent TOML files, found ${#actual[@]}: ${actual[*]}"; result=1; } + + local idx + for idx in "${!expected[@]}"; do + [[ "${actual[$idx]:-missing}" == "${expected[$idx]}" ]] \ + || { echo "expected ${expected[$idx]} at index $idx, found ${actual[$idx]:-missing}"; result=1; } + done + + rm -rf "$dst" + return $result +} + +test_cc_adapter_build_real_agent_corpus_has_required_fields_and_metadata() { + local py; py="$(_python_cmd)" + if [[ -z "$py" ]]; then + echo "SKIP: python not found" + return 0 + fi + + local dst; dst="$(mktemp -d)" + adapter_build "$ROOT" "$dst" + + local result=0 + local files=( + architect + connector + librarian + postman + scribe + seeker + sorter + transcriber + ) + + local agent file content + for agent in "${files[@]}"; do + file="$dst/.codex/agents/$agent.toml" + [[ -f "$file" ]] || { echo "missing generated file: $file"; result=1; continue; } + content="$(cat "$file")" + [[ "$content" == *'name = "'* ]] || { echo "name field missing in $agent.toml"; result=1; } + [[ "$content" == *'description = "'* ]] || { echo "description field missing in $agent.toml"; result=1; } + [[ "$content" == *'model = "'* ]] || { echo "model field missing in $agent.toml"; result=1; } + [[ "$content" == *'model_reasoning_effort = "'* ]] || { echo "model_reasoning_effort missing in $agent.toml"; result=1; } + [[ "$content" == *'sandbox_mode = "'* ]] || { echo "sandbox_mode missing in $agent.toml"; result=1; } + [[ "$content" == *'developer_instructions = '* ]] || { echo "developer_instructions missing in $agent.toml"; result=1; } + [[ "$content" != *'.platform/'* ]] || { echo ".platform/ leaked into $agent.toml"; result=1; } + [[ "$content" != *'DISPATCHER.md'* ]] || { echo "DISPATCHER.md leaked into $agent.toml"; result=1; } + if ! "$py" "$ROOT/tests/support/toml_smoke_check.py" "$file"; then + echo "TOML smoke check failed for $agent.toml" + result=1 + fi + done + + grep -q '^sandbox_mode = "read-only"$' "$dst/.codex/agents/seeker.toml" \ + || { echo 'seeker.toml should be read-only'; result=1; } + grep -q '^sandbox_mode = "workspace-write"$' "$dst/.codex/agents/postman.toml" \ + || { echo 'postman.toml should be workspace-write'; result=1; } + grep -q '^sandbox_mode = "workspace-write"$' "$dst/.codex/agents/architect.toml" \ + || { echo 'architect.toml should be workspace-write'; result=1; } + grep -q '^model = "gpt-5.4"$' "$dst/.codex/agents/architect.toml" \ + || { echo 'architect.toml should map to gpt-5.4'; result=1; } + grep -q '^model_reasoning_effort = "high"$' "$dst/.codex/agents/architect.toml" \ + || { echo 'architect.toml should map to high reasoning effort'; result=1; } + grep -q '^model = "gpt-5.4-mini"$' "$dst/.codex/agents/scribe.toml" \ + || { echo 'scribe.toml should map to gpt-5.4-mini'; result=1; } + grep -q '^model_reasoning_effort = "medium"$' "$dst/.codex/agents/scribe.toml" \ + || { echo 'scribe.toml should map to medium reasoning effort'; result=1; } + + rm -rf "$dst" + return $result +} + +# --------------------------------------------------------------------------- +# Tool-compat rewrite tests (T-01-06) +# --------------------------------------------------------------------------- + +test_cc_rewrite_tool_compat_removes_ask_user_question() { + # AskUserQuestion (backtick and bare) must be rewritten to "ask the user" + local tmp; tmp="$(mktemp)" + cat > "$tmp" <<'EOF' +You MUST use the `AskUserQuestion` tool for every question. +Also call AskUserQuestion once per turn. +EOF + rewrite_tool_compat "$tmp" + local content; content="$(cat "$tmp")" + local result=0 + [[ "$content" != *'AskUserQuestion'* ]] || { echo "AskUserQuestion still present: $content"; result=1; } + [[ "$content" == *'ask the user'* ]] || { echo "'ask the user' not found: $content"; result=1; } + rm -f "$tmp" + return $result +} + +test_cc_rewrite_tool_compat_removes_request_user_input() { + # request_user_input (backtick and bare) must be rewritten to "ask the user" + local tmp; tmp="$(mktemp)" + cat > "$tmp" <<'EOF' +Call `request_user_input` to get the answer. +Also request_user_input should not appear. +EOF + rewrite_tool_compat "$tmp" + local content; content="$(cat "$tmp")" + local result=0 + [[ "$content" != *'request_user_input'* ]] || { echo "request_user_input still present: $content"; result=1; } + [[ "$content" == *'ask the user'* ]] || { echo "'ask the user' not found: $content"; result=1; } + rm -f "$tmp" + return $result +} + +test_cc_rewrite_tool_compat_removes_skill_agent_tool_phrases() { + # "Skill tool" and "Agent tool" must become "invoke the skill" / "invoke the agent" + local tmp; tmp="$(mktemp)" + cat > "$tmp" <<'EOF' +Use the Skill tool to invoke skills. +Use the Agent tool to delegate tasks. +EOF + rewrite_tool_compat "$tmp" + local content; content="$(cat "$tmp")" + local result=0 + [[ "$content" != *'Skill tool'* ]] || { echo "Skill tool still present: $content"; result=1; } + [[ "$content" != *'Agent tool'* ]] || { echo "Agent tool still present: $content"; result=1; } + [[ "$content" == *'invoke the skill'* ]] || { echo "'invoke the skill' not found: $content"; result=1; } + [[ "$content" == *'invoke the agent'* ]] || { echo "'invoke the agent' not found: $content"; result=1; } + rm -f "$tmp" + return $result +} + +test_cc_rewrite_tool_compat_removes_read_glob_grep_bash_tool() { + # "Read tool", "Glob tool", "Grep tool" → "read files"/"search files" + # "Bash tool" → "shell" + local tmp; tmp="$(mktemp)" + cat > "$tmp" <<'EOF' +Use the Read tool for reading files. +Use the Glob tool to find files. +Use the Grep tool to search content. +Use the Bash tool to run commands. +EOF + rewrite_tool_compat "$tmp" + local content; content="$(cat "$tmp")" + local result=0 + [[ "$content" != *'Read tool'* ]] || { echo "Read tool still present: $content"; result=1; } + [[ "$content" != *'Glob tool'* ]] || { echo "Glob tool still present: $content"; result=1; } + [[ "$content" != *'Grep tool'* ]] || { echo "Grep tool still present: $content"; result=1; } + [[ "$content" != *'Bash tool'* ]] || { echo "Bash tool still present: $content"; result=1; } + [[ "$content" == *'read files'* ]] || { echo "'read files' not found: $content"; result=1; } + [[ "$content" == *'search files'* ]] || { echo "'search files' not found: $content"; result=1; } + [[ "$content" == *'shell'* ]] || { echo "'shell' not found: $content"; result=1; } + rm -f "$tmp" + return $result +} + +test_cc_skill_output_contains_no_ask_user_question() { + # Skills translated by adapter_translate_skills must not contain AskUserQuestion + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/skills/create-agent" + cat > "$src/skills/create-agent/SKILL.md" <<'SKILLEOF' +--- +name: create-agent +description: Create a new agent +--- + +You MUST use the `AskUserQuestion` tool for EVERY question. +Call AskUserQuestion once per phase. +SKILLEOF + adapter_translate_skills "$src/skills" "$dst" + local content; content="$(cat "$dst/.agents/skills/create-agent/SKILL.md")" + local result=0 + [[ "$content" != *'AskUserQuestion'* ]] || { echo "AskUserQuestion still present in skill output: $content"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_skill_output_contains_no_request_user_input() { + # Skills translated by adapter_translate_skills must not contain request_user_input + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/skills/inbox" + cat > "$src/skills/inbox/SKILL.md" <<'SKILLEOF' +--- +name: inbox +description: Inbox skill +--- + +Call `request_user_input` to gather details. +SKILLEOF + adapter_translate_skills "$src/skills" "$dst" + local content; content="$(cat "$dst/.agents/skills/inbox/SKILL.md")" + local result=0 + [[ "$content" != *'request_user_input'* ]] || { echo "request_user_input still present in skill output: $content"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_skill_output_contains_no_tool_ish_phrases() { + # Skills must have Read/Glob/Grep/Bash tool references rewritten + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/skills/searcher" + cat > "$src/skills/searcher/SKILL.md" <<'SKILLEOF' +--- +name: searcher +description: Search skill +--- + +Use the Read tool, Glob tool, Grep tool, and Bash tool. +SKILLEOF + adapter_translate_skills "$src/skills" "$dst" + local content; content="$(cat "$dst/.agents/skills/searcher/SKILL.md")" + local result=0 + [[ "$content" != *'Read tool'* ]] || { echo "Read tool still present: $content"; result=1; } + [[ "$content" != *'Glob tool'* ]] || { echo "Glob tool still present: $content"; result=1; } + [[ "$content" != *'Grep tool'* ]] || { echo "Grep tool still present: $content"; result=1; } + [[ "$content" != *'Bash tool'* ]] || { echo "Bash tool still present: $content"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_agents_md_contains_no_skill_agent_tool() { + # Generated AGENTS.md must not contain "Skill tool" or "Agent tool" + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + cat > "$src/DISPATCHER.md" <<'EOF' +# Dispatcher + +Use the Skill tool to invoke skills. Use the Agent tool to delegate. +See .platform/agents/ for agents. +EOF + adapter_translate_dispatcher "$src/DISPATCHER.md" "$dst" + local content; content="$(cat "$dst/AGENTS.md")" + local result=0 + [[ "$content" != *'Skill tool'* ]] || { echo "Skill tool still present in AGENTS.md: $content"; result=1; } + [[ "$content" != *'Agent tool'* ]] || { echo "Agent tool still present in AGENTS.md: $content"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_agent_toml_contains_no_ask_user_question() { + # TOML agent developer_instructions must not contain AskUserQuestion + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/agents" + cat > "$src/agents/wizard.md" <<'AGENTEOF' +--- +name: wizard +description: A wizard agent. +mode: subagent +capabilities: [read] +model: low +--- + +You MUST use the `AskUserQuestion` tool. +Call AskUserQuestion for each step. +AGENTEOF + adapter_translate_agent_toml "$src/agents/wizard.md" "$dst" + local content; content="$(cat "$dst/.codex/agents/wizard.toml")" + local result=0 + [[ "$content" != *'AskUserQuestion'* ]] || { echo "AskUserQuestion still present in agent TOML: $content"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_real_create_agent_skill_has_no_ask_user_question() { + # The real skills/create-agent/SKILL.md (which has AskUserQuestion) must produce + # zero occurrences of AskUserQuestion in the Codex output + local dst; dst="$(mktemp -d)" + adapter_translate_skills "$ROOT/skills" "$dst" + local result=0 + local skill_out="$dst/.agents/skills/create-agent/SKILL.md" + if [[ -f "$skill_out" ]]; then + local content; content="$(cat "$skill_out")" + [[ "$content" != *'AskUserQuestion'* ]] \ + || { echo "AskUserQuestion still present in real create-agent skill output"; result=1; } + [[ "$content" != *'request_user_input'* ]] \ + || { echo "request_user_input still present in real create-agent skill output"; result=1; } + fi + rm -rf "$dst" + return $result +} + +# --------------------------------------------------------------------------- +# Codex dispatcher header (DISP-01 / T-01-08) tests — Task 2 +# --------------------------------------------------------------------------- + +test_cc_agents_md_has_codex_header_marker() { + # Generated AGENTS.md must contain the unique Codex routing header marker + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + echo "# Dispatcher" > "$src/DISPATCHER.md" + adapter_translate_dispatcher "$src/DISPATCHER.md" "$dst" + local result=0 + grep -qF '' "$dst/AGENTS.md" \ + || { echo "CODEX-ROUTING-HEADER marker missing from AGENTS.md"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_agents_md_header_contains_routing_notes() { + # Codex header must include key routing guidance text + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + echo "# Dispatcher" > "$src/DISPATCHER.md" + adapter_translate_dispatcher "$src/DISPATCHER.md" "$dst" + local content; content="$(cat "$dst/AGENTS.md")" + local result=0 + [[ "$content" == *'.codex/agents'* ]] || { echo ".codex/agents mention missing from header"; result=1; } + [[ "$content" == *'.agents/skills'* ]] || { echo ".agents/skills mention missing from header"; result=1; } + [[ "$content" == *'run shell commands'* || "$content" == *'spawn sub-agents'* ]] \ + || { echo "neutral tool language missing from header"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_agents_md_header_is_idempotent() { + # Running adapter_translate_dispatcher twice should not duplicate the header + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + echo "# Dispatcher" > "$src/DISPATCHER.md" + adapter_translate_dispatcher "$src/DISPATCHER.md" "$dst" + adapter_translate_dispatcher "$src/DISPATCHER.md" "$dst" + local count; count="$(grep -c 'CODEX-ROUTING-HEADER' "$dst/AGENTS.md" || true)" + local result=0 + [[ "$count" -eq 1 ]] || { echo "CODEX-ROUTING-HEADER appears $count times (expected 1)"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_agents_md_has_no_tool_names_after_full_build() { + # End-to-end: after adapter_build, AGENTS.md has no unsupported tool names + local src; src="$(mktemp -d)" + local dst; dst="$(mktemp -d)" + mkdir -p "$src/mcp" + cat > "$src/DISPATCHER.md" <<'EOF' +# Dispatcher + +Use the Skill tool to invoke skills. +Use the Agent tool to delegate. +Call `AskUserQuestion` for input. +Use request_user_input as fallback. +EOF + cat > "$src/mcp/servers.yaml" <<'EOF' +servers: + - name: Gmail + type: http + url: "https://gmail.mcp.claude.com/mcp" + env: {} +EOF + adapter_build "$src" "$dst" + local content; content="$(cat "$dst/AGENTS.md")" + local result=0 + [[ "$content" != *'AskUserQuestion'* ]] || { echo "AskUserQuestion in AGENTS.md after build"; result=1; } + [[ "$content" != *'request_user_input'* ]] || { echo "request_user_input in AGENTS.md after build"; result=1; } + [[ "$content" != *'Skill tool'* ]] || { echo "Skill tool in AGENTS.md after build"; result=1; } + [[ "$content" != *'Agent tool'* ]] || { echo "Agent tool in AGENTS.md after build"; result=1; } + grep -qF '' "$dst/AGENTS.md" \ + || { echo "CODEX-ROUTING-HEADER missing after full build"; result=1; } + rm -rf "$src" "$dst" + return $result +} + +test_cc_adapter_build_real_dispatcher_references_codex_compat_contract() { + local dst; dst="$(mktemp -d)" + adapter_build "$ROOT" "$dst" + + local content; content="$(cat "$dst/AGENTS.md")" + local result=0 + [[ "$content" == *'max_depth = 1'* ]] \ + || { echo 'AGENTS.md should mention max_depth = 1'; result=1; } + [[ "$content" == *'.codex/references/codex-cli-compat.md'* ]] \ + || { echo 'AGENTS.md should point to .codex/references/codex-cli-compat.md'; result=1; } + [[ "$content" != *'Skill tool'* ]] || { echo 'Skill tool leaked into real AGENTS.md output'; result=1; } + [[ "$content" != *'Agent tool'* ]] || { echo 'Agent tool leaked into real AGENTS.md output'; result=1; } + [[ "$content" != *'AskUserQuestion'* ]] || { echo 'AskUserQuestion leaked into real AGENTS.md output'; result=1; } + [[ "$content" != *'request_user_input'* ]] || { echo 'request_user_input leaked into real AGENTS.md output'; result=1; } + + rm -rf "$dst" + return $result +} + +test_cc_adapter_build_normalizes_agent_orchestration_for_codex_depth() { + local dst; dst="$(mktemp -d)" + adapter_build "$ROOT" "$dst" + + local content; content="$(cat "$dst/.codex/references/agent-orchestration.md")" + local result=0 + [[ "$content" == *'root context'* || "$content" == *'agents.max_depth = 1'* ]] \ + || { echo 'agent-orchestration.md should explain root-only Codex orchestration'; result=1; } + [[ "$content" != *'max depth 3'* ]] \ + || { echo 'max depth 3 should not appear in Codex agent-orchestration output'; result=1; } + [[ "$content" != *'step 3 of max 3'* ]] \ + || { echo 'step 3 of max 3 should not appear in Codex agent-orchestration output'; result=1; } + + rm -rf "$dst" + return $result +} + +test_cc_adapter_build_generates_codex_compat_reference() { + local dst; dst="$(mktemp -d)" + adapter_build "$ROOT" "$dst" + + local compat="$dst/.codex/references/codex-cli-compat.md" + local result=0 + [[ -f "$compat" ]] || { echo 'codex-cli-compat.md should be generated'; result=1; } + if [[ -f "$compat" ]]; then + local content; content="$(cat "$compat")" + [[ "$content" == *'AskUserQuestion'* ]] || { echo 'compat reference should document AskUserQuestion'; result=1; } + [[ "$content" == *'request_user_input'* ]] || { echo 'compat reference should document request_user_input'; result=1; } + [[ "$content" == *'spawn_agent'* ]] || { echo 'compat reference should mention spawn_agent'; result=1; } + [[ "$content" == *'.codex/config.toml'* ]] || { echo 'compat reference should mention .codex/config.toml'; result=1; } + [[ "$content" == *'.agents/skills/'* ]] || { echo 'compat reference should mention .agents/skills/'; result=1; } + fi + + rm -rf "$dst" + return $result +} + +test_cc_normalize_codex_routing_contract_rewrites_depth_and_tool_tokens() { + local tmp; tmp="$(mktemp)" + cat > "$tmp" <<'EOF' +Use the Skill tool, Agent tool, and AskUserQuestion. +Call request_user_input if AskUserQuestion is unavailable. +You are step 3 of max 3. The dispatcher may recurse to max depth 3. +EOF + + normalize_codex_routing_contract "$tmp" + + local content; content="$(cat "$tmp")" + local result=0 + [[ "$content" != *'Skill tool'* ]] || { echo 'Skill tool should be normalized'; result=1; } + [[ "$content" != *'Agent tool'* ]] || { echo 'Agent tool should be normalized'; result=1; } + [[ "$content" != *'AskUserQuestion'* ]] || { echo 'AskUserQuestion should be normalized'; result=1; } + [[ "$content" != *'request_user_input'* ]] || { echo 'request_user_input should be normalized'; result=1; } + [[ "$content" != *'step 3 of max 3'* ]] || { echo 'step 3 of max 3 should be normalized'; result=1; } + [[ "$content" != *'max depth 3'* ]] || { echo 'max depth 3 should be normalized'; result=1; } + [[ "$content" == *'max_depth = 1'* || "$content" == *'root context'* ]] \ + || { echo 'normalized output should mention the root-only Codex contract'; result=1; } + + rm -f "$tmp" + return $result +} diff --git a/tests/scripts/codex-cli-install.test.sh b/tests/scripts/codex-cli-install.test.sh new file mode 100644 index 0000000..90056ec --- /dev/null +++ b/tests/scripts/codex-cli-install.test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Tests for Codex CLI install/update flows in scripts/launchme.sh and scripts/updateme.sh +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +test_codex_cli_scripts_define_explicit_platform_cases() { + local result=0 + grep -q 'codex-cli)' "$ROOT/scripts/launchme.sh" \ + || { echo 'launchme.sh should define a codex-cli) case'; result=1; } + grep -q 'codex-cli)' "$ROOT/scripts/updateme.sh" \ + || { echo 'updateme.sh should define a codex-cli) case'; result=1; } + grep -q '\.codex/agents' "$ROOT/scripts/updateme.sh" \ + || { echo 'updateme.sh should detect .codex/agents installs'; result=1; } + return $result +} + +test_launchme_installs_codex_cli_layout() { + local vault; vault="$(mktemp -d)" + local log; log="$(mktemp)" + + if ! bash "$ROOT/scripts/launchme.sh" --platform codex-cli --target "$vault" >"$log" 2>&1; then + cat "$log" + rm -rf "$vault" "$log" + return 1 + fi + + local result=0 + [[ -f "$vault/AGENTS.md" ]] || { echo 'AGENTS.md missing after codex-cli install'; result=1; } + [[ -f "$vault/.codex/config.toml" ]] || { echo '.codex/config.toml missing after codex-cli install'; result=1; } + [[ -d "$vault/.codex/agents" ]] || { echo '.codex/agents missing after codex-cli install'; result=1; } + [[ -f "$vault/.codex/agents/transcriber.toml" || -f "$vault/.codex/agents/architect.toml" ]] \ + || { echo 'core codex agent TOML missing after install'; result=1; } + [[ -f "$vault/.agents/skills/onboarding/SKILL.md" ]] || { echo '.agents/skills/onboarding/SKILL.md missing after install'; result=1; } + + if [[ -f "$vault/AGENTS.md" ]]; then + grep -q 'Codex CLI' "$vault/AGENTS.md" \ + || { echo 'AGENTS.md should contain Codex CLI guidance'; result=1; } + fi + if [[ -f "$vault/.codex/config.toml" ]]; then + grep -q '\[agents\]' "$vault/.codex/config.toml" \ + || { echo '.codex/config.toml should contain [agents]'; result=1; } + fi + + rm -rf "$vault" "$log" + return $result +} + +test_updateme_auto_detects_and_refreshes_codex_cli_install() { + local vault; vault="$(mktemp -d)" + local install_log; install_log="$(mktemp)" + local update_log; update_log="$(mktemp)" + + if ! bash "$ROOT/scripts/launchme.sh" --platform codex-cli --target "$vault" >"$install_log" 2>&1; then + cat "$install_log" + rm -rf "$vault" "$install_log" "$update_log" + return 1 + fi + + printf 'stale dispatcher\n' > "$vault/AGENTS.md" + printf 'stale config\n' > "$vault/.codex/config.toml" + + if ! printf 'c\n' | bash "$ROOT/scripts/updateme.sh" --target "$vault" >"$update_log" 2>&1; then + cat "$update_log" + rm -rf "$vault" "$install_log" "$update_log" + return 1 + fi + + local result=0 + grep -q 'Detected platform: codex-cli' "$update_log" \ + || { echo 'updateme.sh should auto-detect codex-cli'; result=1; } + grep -q 'Codex CLI' "$vault/AGENTS.md" \ + || { echo 'update should refresh AGENTS.md content'; result=1; } + grep -q '\[agents\]' "$vault/.codex/config.toml" \ + || { echo 'update should refresh .codex/config.toml content'; result=1; } + [[ -d "$vault/.codex/agents" ]] || { echo '.codex/agents should remain after update'; result=1; } + [[ -d "$vault/.agents/skills" ]] || { echo '.agents/skills should remain after update'; result=1; } + + rm -rf "$vault" "$install_log" "$update_log" + return $result +} diff --git a/tests/scripts/platform-parity.test.sh b/tests/scripts/platform-parity.test.sh new file mode 100644 index 0000000..b173587 --- /dev/null +++ b/tests/scripts/platform-parity.test.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# ============================================================================= +# tests/scripts/platform-parity.test.sh — Four-platform build parity suite +# ============================================================================= +# Proves that Codex CLI changes did not regress Claude Code, Gemini CLI, +# OpenCode, or Codex CLI build artifacts. Runs as part of tests/run.sh. +# ============================================================================= +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# --------------------------------------------------------------------------- +# test_platform_build_matrix_produces_expected_dispatchers_and_roots +# +# Builds all four platforms and asserts each produces the correct dispatcher, +# platform config directory, and at least one canonical agent file. +# --------------------------------------------------------------------------- +test_platform_build_matrix_produces_expected_dispatchers_and_roots() { + local result=0 + + # Platform → expected artifacts map + # Format: "||" + declare -A EXPECTED + EXPECTED["claude-code"]="dist/claude-code/CLAUDE.md|dist/claude-code/.claude/agents/architect.md|dist/claude-code/.mcp.json" + EXPECTED["gemini-cli"]="dist/gemini-cli/GEMINI.md|dist/gemini-cli/.gemini/agents/architect.md|dist/gemini-cli/.gemini/settings.json" + EXPECTED["opencode"]="dist/opencode/AGENTS.md|dist/opencode/.opencode/agents/architect.md|dist/opencode/opencode.json" + EXPECTED["codex-cli"]="dist/codex-cli/AGENTS.md|dist/codex-cli/.codex/agents/architect.toml|dist/codex-cli/.codex/config.toml" + + for platform in claude-code gemini-cli opencode codex-cli; do + if ! bash "$ROOT/scripts/build.sh" --platform "$platform" >/dev/null 2>&1; then + echo "FAIL: build failed for platform: $platform" + result=1 + continue + fi + + IFS='|' read -r dispatcher agent_file config_file <<< "${EXPECTED[$platform]}" + + [[ -f "$ROOT/$dispatcher" ]] \ + || { echo "FAIL [$platform]: dispatcher missing: $dispatcher"; result=1; } + [[ -f "$ROOT/$agent_file" ]] \ + || { echo "FAIL [$platform]: agent file missing: $agent_file"; result=1; } + [[ -f "$ROOT/$config_file" ]] \ + || { echo "FAIL [$platform]: config file missing: $config_file"; result=1; } + done + + # Additional Codex-specific: skills directory + [[ -f "$ROOT/dist/codex-cli/.agents/skills/onboarding/SKILL.md" ]] \ + || { echo "FAIL [codex-cli]: .agents/skills/onboarding/SKILL.md missing"; result=1; } + + return $result +} + +# --------------------------------------------------------------------------- +# test_claude_snapshot_regression_still_passes_after_codex_changes +# +# Runs the Claude Code snapshot regression test. Fails immediately if the +# snapshot diff reports any drift — Codex changes must not touch Claude output. +# --------------------------------------------------------------------------- +test_claude_snapshot_regression_still_passes_after_codex_changes() { + local log; log="$(mktemp)" + if ! bash "$ROOT/tests/regression/run.sh" >"$log" 2>&1; then + echo "FAIL: Claude snapshot regression reported drift:" + cat "$log" + rm -f "$log" + return 1 + fi + rm -f "$log" + return 0 +} + +# --------------------------------------------------------------------------- +# test_codex_install_update_gate_remains_in_the_full_suite +# +# Asserts that the Codex CLI install/update test file still exists and +# defines both required test function names. This gate ensures the parity +# suite does not accidentally exclude Codex install regression coverage. +# --------------------------------------------------------------------------- +test_codex_install_update_gate_remains_in_the_full_suite() { + local install_test="$ROOT/tests/scripts/codex-cli-install.test.sh" + local result=0 + + [[ -f "$install_test" ]] \ + || { echo "FAIL: tests/scripts/codex-cli-install.test.sh does not exist"; return 1; } + + grep -q 'test_launchme_installs_codex_cli_layout' "$install_test" \ + || { echo "FAIL: test_launchme_installs_codex_cli_layout not found in codex-cli-install.test.sh"; result=1; } + grep -q 'test_updateme_auto_detects_and_refreshes_codex_cli_install' "$install_test" \ + || { echo "FAIL: test_updateme_auto_detects_and_refreshes_codex_cli_install not found in codex-cli-install.test.sh"; result=1; } + + return $result +} diff --git a/tests/support/toml_smoke_check.py b/tests/support/toml_smoke_check.py new file mode 100644 index 0000000..67ef80d --- /dev/null +++ b/tests/support/toml_smoke_check.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +tests/support/toml_smoke_check.py — Validate TOML files generated by Phase 1 adapters. + +Usage: + python tests/support/toml_smoke_check.py path/to/file.toml + +Exit 0 if the file parses under the subset of TOML features used in Phase 1: + - Standard [table] headers + - Quoted table names: [mcp_servers."Google Calendar"] + - key = "string" + - key = true / false + - key = ["a", "b"] (array of strings) + - env = { KEY = "VALUE" } (inline table) + - Comments (# ...) + - Blank lines + - key = '''...''' (multiline literal string, any content) + - key = \"\"\"...\"\"\" (multiline basic string, any content) + +Exit 1 with a clear error message if parsing fails. +""" + +import re +import sys + + +# --------------------------------------------------------------------------- +# Token patterns for Phase 1 TOML subset +# --------------------------------------------------------------------------- + +# Blank line or comment +RE_BLANK = re.compile(r'^\s*(#.*)?$') + +# Standard table header: [agents] or [mcp_servers.Gmail] +RE_TABLE = re.compile(r'^\[([A-Za-z0-9_.-]+)\]\s*(#.*)?$') + +# TOML basic string content used inside quoted table keys for this test suite. +# The generated keys only need literal characters plus escaped quote/backslash. +# Keeping the subset narrow helps catch accidental raw backslashes such as +# [mcp_servers."foo\bar"], which would change the key meaning. +_TOML_ESCAPE = r'\\(?:["\\])' +_TOML_SAFE_CHAR = r'[^"\\]' +_TOML_QSTR = rf'(?:{_TOML_SAFE_CHAR}|{_TOML_ESCAPE})*' + +# Quoted table header: [mcp_servers."Google Calendar"] +RE_TABLE_QUOTED = re.compile( + rf'^\[([A-Za-z0-9_.-]+"{_TOML_QSTR}")\]\s*(#.*)?$' +) + +# key = "string" +RE_KEY_STRING = re.compile(r'^[A-Za-z0-9_.-]+\s*=\s*"([^"\\]|\\.)*"\s*(#.*)?$') + +# key = true or key = false +RE_KEY_BOOL = re.compile(r'^[A-Za-z0-9_.-]+\s*=\s*(true|false)\s*(#.*)?$') + +# key = integer +RE_KEY_INT = re.compile(r'^[A-Za-z0-9_.-]+\s*=\s*-?\d+\s*(#.*)?$') + +# key = ["a", "b", ...] (array of strings — may span a single line only) +RE_KEY_ARRAY = re.compile(r'^[A-Za-z0-9_.-]+\s*=\s*\[.*\]\s*(#.*)?$') + +# key = { KEY = "VALUE", ... } (inline table — single line) +RE_KEY_INLINE_TABLE = re.compile(r'^[A-Za-z0-9_.-]+\s*=\s*\{.*\}\s*(#.*)?$') + +# key = ''' (start of multiline literal string) +RE_MULTILINE_LIT_START = re.compile(r"^[A-Za-z0-9_.-]+\s*=\s*'''") + +# key = """ (start of multiline basic string) +RE_MULTILINE_BASIC_START = re.compile(r'^[A-Za-z0-9_.-]+\s*=\s*"""') + +# Closing delimiter for multiline literal: line that is exactly ''' or ends with ''' +RE_MULTILINE_LIT_END = re.compile(r"'''$") + +# Closing delimiter for multiline basic: line that is exactly """ or ends with """ +RE_MULTILINE_BASIC_END = re.compile(r'"""$') + +# Collect all line-level patterns (order matters — most specific first) +LINE_PATTERNS = [ + RE_BLANK, + RE_TABLE_QUOTED, + RE_TABLE, + RE_KEY_STRING, + RE_KEY_BOOL, + RE_KEY_INT, + RE_KEY_ARRAY, + RE_KEY_INLINE_TABLE, + RE_MULTILINE_LIT_START, + RE_MULTILINE_BASIC_START, +] + + +def check_file(path: str) -> None: + try: + with open(path, encoding='utf-8') as fh: + lines = fh.readlines() + except FileNotFoundError: + print(f"ERROR: File not found: {path}", file=sys.stderr) + sys.exit(1) + except OSError as exc: + print(f"ERROR: Cannot read {path}: {exc}", file=sys.stderr) + sys.exit(1) + + errors = [] + in_multiline_lit = False # inside ''' ... ''' + in_multiline_basic = False # inside """ ... """ + + for lineno, raw in enumerate(lines, start=1): + line = raw.rstrip('\n') + + # Inside a multiline literal string: any content is valid until ''' + if in_multiline_lit: + if RE_MULTILINE_LIT_END.search(line): + in_multiline_lit = False + # All lines inside a multiline literal are valid — no error + continue + + # Inside a multiline basic string: any content is valid until """ + if in_multiline_basic: + if RE_MULTILINE_BASIC_END.search(line): + in_multiline_basic = False + # All lines inside a multiline basic string are valid — no error + continue + + # Check if this line opens a multiline string + if RE_MULTILINE_LIT_START.match(line): + # Check if it's also closed on the same line (inline) + rest = re.sub(r"^[A-Za-z0-9_.-]+\s*=\s*'''", "", line) + if rest.endswith("'''"): + pass # Single-line multiline literal (unusual but valid) + else: + in_multiline_lit = True + continue + + if RE_MULTILINE_BASIC_START.match(line): + rest = re.sub(r'^[A-Za-z0-9_.-]+\s*=\s*"""', "", line) + if rest.endswith('"""'): + pass # Single-line multiline basic (unusual but valid) + else: + in_multiline_basic = True + continue + + matched = any(pat.match(line) for pat in LINE_PATTERNS) + if not matched: + errors.append(f" line {lineno}: unexpected syntax: {line!r}") + + if in_multiline_lit: + errors.append(" Unterminated multiline literal string (''' not closed)") + if in_multiline_basic: + errors.append(' Unterminated multiline basic string (""" not closed)') + + if errors: + print(f"TOML parse error in {path}:", file=sys.stderr) + for err in errors: + print(err, file=sys.stderr) + sys.exit(1) + + print(f"OK: {path}") + + +if __name__ == '__main__': + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(2) + check_file(sys.argv[1])