vault-sync: 2026-09-16 00:00 (jimmy)

This commit is contained in:
jimmy
2026-09-16 00:00:30 -07:00
parent cf3c882c94
commit 00719428b2
194 changed files with 42289 additions and 0 deletions
@@ -0,0 +1,317 @@
#!/usr/bin/env bash
# =============================================================================
# adapters/claude-code/adapter.sh — Claude Code framework adapter
# =============================================================================
# Sourced by scripts/build.sh AFTER adapters/lib.sh.
# Translates source files into a dist/claude-code/ tree that mirrors what
# Claude Code expects in the user's vault.
# =============================================================================
CC_PLATFORM="claude-code"
CC_FW_DIR="claude"
CC_DISPATCHER="CLAUDE.md"
# Capability → CC tools mapping. Each capability expands into one or more
# Claude Code tool names. The expansion is order-preserving.
cc_capability_to_tools() {
local cap="$1"
case "$cap" in
read) echo "Read" ;;
write) echo "Write" ;;
edit) echo "Edit" ;;
bash) echo "Bash" ;;
webfetch) echo "WebFetch" ;;
websearch) echo "WebSearch" ;;
notebook) echo "NotebookEdit" ;;
task) echo "Task" ;;
todo) echo "TodoWrite" ;;
*) echo "" ;;
esac
}
# Event vocabulary → CC native event mapping.
cc_event_to_native() {
local event="$1"
case "$event" in
before-tool-use) echo "PreToolUse" ;;
after-tool-use) echo "PostToolUse" ;;
on-notification) echo "Notification" ;;
on-session-start) echo "SessionStart" ;;
on-prompt-submit) echo "UserPromptSubmit" ;;
*) echo "" ;;
esac
}
# adapter_finalize <source_root> <dest_root>
# Writes any framework-specific top-level files (plugin manifest, etc.).
adapter_finalize() {
local src="$1" dst="$2"
if [[ -f "$src/.claude-plugin/plugin.json" ]]; then
mkdir -p "$dst/.claude-plugin"
cp "$src/.claude-plugin/plugin.json" "$dst/.claude-plugin/plugin.json"
fi
}
# adapter_translate_mcp <source_mcp_dir> <dest_root>
# Reads mcp/servers.yaml and writes dst/.mcp.json with mcpServers key.
adapter_translate_mcp() {
local src="$1" dst="$2"
local yaml="$src/servers.yaml"
[[ -f "$yaml" ]] || return 0
local out="$dst/.mcp.json"
mkdir -p "$dst"
# Parse the YAML into a JSON object {server_name: {command, args, env}}
local json='{}'
local current_name="" current_cmd="" current_url="" current_type=""
while IFS= read -r line; do
case "$line" in
*"- name:"*)
# Flush previous server
if [[ -n "$current_name" ]]; then
if [[ -n "$current_cmd" ]]; then
local first_arg="${current_cmd%% *}"
local rest="${current_cmd#* }"
local args_json='[]'
if [[ "$rest" != "$current_cmd" ]]; then
args_json="$(echo "$rest" | jq -R 'split(" ")')"
fi
json="$(echo "$json" | jq --arg n "$current_name" --arg c "$first_arg" --argjson a "$args_json" '.[$n] = {command: $c, args: $a, env: {}}')"
elif [[ -n "$current_url" ]]; then
json="$(echo "$json" | jq --arg n "$current_name" --arg u "$current_url" --arg t "$current_type" '.[$n] = {type: $t, url: $u}')"
fi
fi
current_name="$(echo "$line" | sed 's/.*- name:[[:space:]]*//' | tr -d '"')"
current_cmd=""
current_url=""
current_type="http"
;;
*"command:"*"["*)
current_cmd="$(echo "$line" | sed 's/.*command:[[:space:]]*\[//' | sed 's/\][[:space:]]*$//' | tr -d '"' | sed 's/,[[:space:]]*/\ /g')"
;;
*"url:"*)
current_url="$(echo "$line" | sed 's/.*url:[[:space:]]*//' | tr -d '"')"
;;
*"type:"*)
current_type="$(echo "$line" | sed 's/.*type:[[:space:]]*//' | tr -d '"')"
;;
esac
done < "$yaml"
# Flush the last server
if [[ -n "$current_name" ]]; then
if [[ -n "$current_cmd" ]]; then
local first_arg="${current_cmd%% *}"
local rest="${current_cmd#* }"
local args_json='[]'
if [[ "$rest" != "$current_cmd" ]]; then
args_json="$(echo "$rest" | jq -R 'split(" ")')"
fi
json="$(echo "$json" | jq --arg n "$current_name" --arg c "$first_arg" --argjson a "$args_json" '.[$n] = {command: $c, args: $a, env: {}}')"
elif [[ -n "$current_url" ]]; then
json="$(echo "$json" | jq --arg n "$current_name" --arg u "$current_url" --arg t "$current_type" '.[$n] = {type: $t, url: $u}')"
fi
fi
echo "$json" | jq '{mcpServers: .}' > "$out"
}
# Helper: convert match-tool tokens to CC matcher syntax (e.g., "edit write" → "Edit|Write")
cc_match_tool_to_matcher() {
local tokens="$1"
local matcher=""
for t in $tokens; do
local cap_t
cap_t="$(echo "$t" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')"
if [[ -z "$matcher" ]]; then
matcher="$cap_t"
else
matcher="$matcher|$cap_t"
fi
done
echo "$matcher"
}
# Neutral model tier → Claude Code model name.
cc_model_to_native() {
local model="$1"
case "$model" in
*/*) echo "$model" ;; # already qualified — passthrough
low) echo "haiku" ;;
mid) echo "sonnet" ;;
high) echo "opus" ;;
*) echo "$model" ;; # unknown — passthrough
esac
}
# adapter_translate_hooks <source_hooks_dir> <dest_root>
adapter_translate_hooks() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local out_dir="$dst/.claude/hooks"
mkdir -p "$out_dir"
local template; template="$(dirname "${BASH_SOURCE[0]}")/templates/cc-hook-wrapper.sh.tmpl"
# Accumulate hook entries here, by event type
local pre_entries="" post_entries="" notif_entries="" sess_entries="" prompt_entries=""
while IFS= read -r yaml; do
[[ -z "$yaml" ]] && continue
should_include "$yaml" "$CC_PLATFORM" || continue
local meta; meta="$(parse_hook_yaml "$yaml")"
local name; name="$(echo "$meta" | grep '^name=' | head -1 | cut -d= -f2- || true)"
local script; script="$(echo "$meta" | grep '^script=' | head -1 | cut -d= -f2- || true)"
local event; event="$(echo "$meta" | grep '^event=' | head -1 | cut -d= -f2- || true)"
local match_tool; match_tool="$(echo "$meta" | grep '^match-tool=' | head -1 | cut -d= -f2- || true)"
# Copy the bash script
cp "$src/$script" "$out_dir/$script"
chmod +x "$out_dir/$script"
rewrite_platform_paths "$out_dir/$script" "$CC_FW_DIR" "$CC_DISPATCHER"
# Generate the wrapper
local wrapper_file="$out_dir/${name}-wrapper.sh"
local cc_event; cc_event="$(cc_event_to_native "$event")"
sed -e "s/__HOOK_NAME__/$name/g" -e "s/__EVENT_NAME__/$event/g" "$template" > "$wrapper_file"
chmod +x "$wrapper_file"
# Build the settings.json entry for this hook
local matcher=""
[[ -n "$match_tool" ]] && matcher="$(cc_match_tool_to_matcher "$match_tool")"
local entry; entry="$(jq -cn \
--arg cmd ".claude/hooks/${name}-wrapper.sh" \
--arg matcher "$matcher" \
'{matcher: $matcher, hooks: [{type: "command", command: ("bash " + $cmd)}]}')"
case "$cc_event" in
PreToolUse) pre_entries="$pre_entries$entry"$'\n' ;;
PostToolUse) post_entries="$post_entries$entry"$'\n' ;;
Notification) notif_entries="$notif_entries$entry"$'\n' ;;
SessionStart) sess_entries="$sess_entries$entry"$'\n' ;;
UserPromptSubmit) prompt_entries="$prompt_entries$entry"$'\n' ;;
esac
done < <(enumerate_hooks "$src")
# Compose the final settings.json
local settings; settings='{"hooks":{}}'
for ev_pair in "PreToolUse:$pre_entries" "PostToolUse:$post_entries" "Notification:$notif_entries" "SessionStart:$sess_entries" "UserPromptSubmit:$prompt_entries"; do
local ev_name="${ev_pair%%:*}"
local ev_data="${ev_pair#*:}"
[[ -z "$ev_data" ]] && continue
local arr; arr="$(echo "$ev_data" | jq -cs '.')"
settings="$(echo "$settings" | jq --arg ev "$ev_name" --argjson arr "$arr" '.hooks[$ev] = $arr')"
done
mkdir -p "$dst/.claude"
echo "$settings" | jq '.' > "$dst/.claude/settings.json"
}
# adapter_translate_agents <source_agents_dir> <dest_root>
# For each *.md in source_agents_dir, translate the capabilities frontmatter
# into a CC tools: allowlist and write to dest_root/.claude/agents/<name>.md.
adapter_translate_agents() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local out_dir="$dst/.claude/agents"
mkdir -p "$out_dir"
while IFS= read -r agent; do
[[ -z "$agent" ]] && continue
should_include "$agent" "$CC_PLATFORM" || continue
local name; name="$(parse_frontmatter "$agent" name)"
local model; model="$(parse_frontmatter "$agent" model)"
model="$(cc_model_to_native "$model")"
local caps; caps="$(parse_capabilities "$agent")"
# Build tools allowlist by expanding each capability.
# read → Read, Glob, Grep; other capabilities follow.
local tools=""
for cap in $caps; do
local expansion; expansion="$(cc_capability_to_tools "$cap")"
[[ -n "$expansion" ]] || continue
for tool in $expansion; do
if [[ -z "$tools" ]]; then
tools="$tool"
else
tools="$tools, $tool"
fi
done
# If this was "read", immediately append Glob and Grep
if [[ "$cap" == "read" ]]; then
tools="$tools, Glob, Grep"
fi
done
local out_file="$out_dir/$(basename "$agent")"
{
echo "---"
echo "name: $name"
# Copy description block (may be folded YAML with continuation lines)
awk '{ sub(/\r$/, "") } /^---$/{n++; next} n==1 && /^description:/{print; in_desc=1; next} n==1 && in_desc && /^[[:space:]]/{print; next} n==1 && in_desc && !/^[[:space:]]/{in_desc=0} n>=2{exit}' "$agent"
echo "tools: $tools"
echo "model: $model"
echo "---"
agent_body "$agent"
} > "$out_file"
rewrite_platform_paths "$out_file" "$CC_FW_DIR" "$CC_DISPATCHER"
done < <(enumerate_agents "$src")
}
# adapter_translate_skills <source_skills_dir> <dest_root>
# Copies each skill directory's SKILL.md into dest_root/.claude/skills/<name>/.
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/.claude/skills/$name"
mkdir -p "$out"
cp "${skill_dir}SKILL.md" "$out/SKILL.md"
rewrite_platform_paths "$out/SKILL.md" "$CC_FW_DIR" "$CC_DISPATCHER"
done
}
# adapter_translate_references <source_refs_dir> <dest_root>
# Verbatim copy of *.md into dest_root/.claude/references/.
adapter_translate_references() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local out="$dst/.claude/references"
mkdir -p "$out"
for f in "$src"/*.md; do
[[ -f "$f" ]] || continue
should_include "$f" "$CC_PLATFORM" || continue
cp "$f" "$out/"
rewrite_platform_paths "$out/$(basename "$f")" "$CC_FW_DIR" "$CC_DISPATCHER"
done
}
# adapter_translate_dispatcher <source_dispatcher_md> <dest_dir>
# Copies the source DISPATCHER.md to dest_dir/CLAUDE.md (no content change).
adapter_translate_dispatcher() {
local src="$1" dst="$2"
[[ -f "$src" ]] || return 0
mkdir -p "$dst"
cp "$src" "$dst/CLAUDE.md"
rewrite_platform_paths "$dst/CLAUDE.md" "$CC_FW_DIR" "$CC_DISPATCHER"
}
# adapter_build <source_dir> <dest_dir>
# The single entry point invoked by scripts/build.sh.
adapter_build() {
local src="$1" dst="$2"
rm -rf "$dst"
mkdir -p "$dst"
adapter_translate_dispatcher "$src/DISPATCHER.md" "$dst"
adapter_translate_references "$src/references" "$dst"
adapter_translate_skills "$src/skills" "$dst"
adapter_translate_agents "$src/agents" "$dst"
adapter_translate_hooks "$src/hooks" "$dst"
adapter_translate_mcp "$src/mcp" "$dst"
adapter_finalize "$src" "$dst"
}
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# =============================================================================
# Generated by adapters/claude-code/adapter.sh — do not edit.
# Wrapper for hook: __HOOK_NAME__
# Reads Claude Code native PreToolUse/PostToolUse/Notification JSON from stdin,
# transforms it into the neutral schema, and pipes the result to __HOOK_NAME__.sh.
# =============================================================================
set -eo pipefail
INPUT=$(cat)
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Translate CC native fields to the neutral schema
NEUTRAL=$(echo "$INPUT" | jq -c '{
event: "__EVENT_NAME__",
tool: (.tool_name // ""),
args: (.tool_input // {title: .title, message: .message}),
session_id: (.session_id // ""),
cwd: (.cwd // ""),
framework: "claude-code",
platform_dir: ".claude",
dispatcher_name: "CLAUDE.md"
}')
echo "$NEUTRAL" | bash "$HOOK_DIR/__HOOK_NAME__.sh"
@@ -0,0 +1,680 @@
#!/usr/bin/env bash
# =============================================================================
# adapters/codex-cli/adapter.sh — Codex CLI framework adapter
# =============================================================================
# Sourced by scripts/build.sh AFTER adapters/lib.sh.
# Translates source files into a dist/codex-cli/ tree that mirrors what
# Codex CLI expects in the user's vault.
#
# Codex CLI specifics:
# - Dispatcher file: AGENTS.md (same as OpenCode)
# - Platform config dir: .codex/
# - MCP config: .codex/config.toml (TOML format with [mcp_servers.*] tables)
# - Skills dir: .agents/skills/<name>/SKILL.md
# =============================================================================
CC_PLATFORM="codex-cli"
CC_FW_DIR="codex"
CC_DISPATCHER="AGENTS.md"
# rewrite_codex_paths <file>
# Applies Codex-specific targeted path rewrites in-place.
# This is narrower than the shared rewrite_platform_paths helper because Codex
# uses mixed destination roots depending on the source path.
rewrite_codex_paths() {
local file="$1"
[[ -f "$file" ]] || return 0
perl -i -pe '
s|\.platform/agents/|.codex/agents/|g;
s|\.platform/references/|.codex/references/|g;
s|\.platform/skills/|.agents/skills/|g;
s|\.platform/|.codex/|g;
s|DISPATCHER\.md|AGENTS.md|g;
' "$file"
}
# rewrite_tool_compat <file>
# Rewrites unsupported or platform-specific tool references in a file to
# Codex-compatible neutral language. Applied AFTER rewrite_platform_paths so
# path references are already resolved.
#
# Rewrites applied (T-01-06):
# - `AskUserQuestion` / AskUserQuestion → "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 <file>
# Rewrites copied dispatcher/reference content so the generated Codex output
# describes one coherent contract: root-context orchestration, bounded child
# agents, and direct-chat confirmations under agents.max_depth = 1.
normalize_codex_routing_contract() {
local file="$1"
[[ -f "$file" ]] || return 0
rewrite_tool_compat "$file"
perl -0pi -e '
s/handle complex, multi-step, or conversational flows\. Invoke them via the \*\*invoke the skill\*\*\. They run in the main conversation context \(multi-turn state is preserved\)\./handle complex, multi-step, or conversational flows. In Codex, the root context follows the relevant skill instructions directly so multi-turn state stays in one chat./g;
s/handle reactive, single-shot operations\. Invoke them via the \*\*invoke the agent\*\*\. They run as subprocesses\./handle reactive, bounded tasks. In Codex, the root context may spawn a bounded child agent when delegation is worth it, but orchestration decisions stay in the root context because `agents.max_depth = 1`./g;
s/If a user message matches a skill trigger, the skill is invoked via the \*\*invoke the skill\*\* \(not the invoke the agent\)\. The dispatcher does NOT also invoke the source agent\./If a user message matches a skill trigger, the root context follows that skill directly in the main conversation. Do not also spawn a child agent for the same trigger./g;
s/Skills run in the \*\*main conversation context\*\*, preserving multi-turn state\. This is different from agents, which run as subprocesses\./Skills stay in the main conversation context so multi-turn state remains in one chat. Child agents are only for bounded side tasks./g;
s/check registry, check call chain, max depth 3/check the registry and decide in the root context whether another bounded child task is still necessary under `agents.max_depth = 1`/g;
s/Skills count as step 1 in the call chain when they produce agent suggestions\./Skills do not consume extra child depth because the root context executes them directly./g;
s/The dispatcher should confirm with the user first:/The root context should confirm with the user directly in chat first:/g;
s/asks the user if they want the Architect to create a custom agent/asks the user directly in chat whether they want a custom agent/g;
s/"Call chain so far: \[scribe, architect\]\. You are step 3 of max 3\."/"Root context history: [scribe, architect]. If another bounded child task is still useful, the root context decides whether to spawn it within `agents.max_depth = 1`."/g;
s/\*\*Max depth: 3\*\*: no more than 3 agents per user request/\*\*Child depth: `agents.max_depth = 1`\*\*: a spawned child may finish one bounded task, then the root context decides what happens next/g;
s/If the dispatcher would need a 4th agent, it:/If deeper recursion would be required, the root context returns the current results to the user and decides the next step in chat:/g;
s/\bmax depth 3\b/`agents.max_depth = 1`/g;
s/\bstep 3 of max 3\b/root-context follow-up after a bounded child task/g;
s/\bmax depth reached\b/root-context delegation limit reached/g;
s/No duplicates: never invoke the same agent twice in one chain/No duplicates: do not spawn the same child agent twice for the same request/g;
s/No circular patterns: if Agent A suggests Agent B and B is already in the chain, skip/No circular patterns: if a suggested child is already in the root decision history, skip it/g;
s/Do NOT call other agents/Do NOT spawn or imply deeper child recursion/g;
s/only the dispatcher invokes agents/only the root context decides when to spawn a bounded child agent/g;
' "$file"
perl -0pi -e '
s/\binvoke the skill\b/follow the skill instructions directly in the root context/g;
s/\binvoke the agent\b/spawn a bounded child agent from the root context/g;
s/\bask the user\b/ask the user directly in chat and wait for the reply/g;
' "$file"
}
# _cc_prepend_codex_header <file>
# Prepends the Codex-specific routing workaround header to a file (T-01-08).
# Uses a unique marker (<!-- CODEX-ROUTING-HEADER -->) so tests can detect it
# and so repeated builds are idempotent.
_cc_prepend_codex_header() {
local file="$1"
[[ -f "$file" ]] || return 0
# Idempotency: skip if header is already present
grep -qF '<!-- CODEX-ROUTING-HEADER -->' "$file" && return 0
local header
header="$(cat <<'HEADER'
<!-- CODEX-ROUTING-HEADER -->
<!-- Generated by adapters/codex-cli/adapter.sh — do not edit manually -->
## Codex CLI — Routing Notes
> **Context:** Codex CLI loads this file as the root dispatcher. Due to
> multi-agent depth limits (`agents.max_depth = 1` by default), all
> orchestration decisions stay in this root context. Spawned agents emit
> a `### Suggested next agent` signal; the root context then decides whether
> to continue with another agent.
**Custom agents** live in `.codex/agents/*.toml` and are discovered automatically.
**Skills** live in `.agents/skills/` inside the project (or `$HOME/.agents/skills/`).
**Compatibility guide:** see `.codex/references/codex-cli-compat.md` for source-to-Codex mappings, approval wording, and troubleshooting.
**Tool language:** Use neutral, conceptual wording — "run shell commands",
"edit files", "spawn sub-agents", "wait for sub-agents" — rather than
hard-coded tool identifiers that may not exist across all Codex versions.
---
HEADER
)"
# Prepend header before existing content
local tmp; tmp="$(mktemp)"
printf '%s\n' "$header" > "$tmp"
cat "$file" >> "$tmp"
mv "$tmp" "$file"
}
# adapter_translate_dispatcher <source_dispatcher_md> <dest_dir>
# Copies the source DISPATCHER.md to dest_dir/AGENTS.md (Codex CLI's vault-root
# dispatcher filename). Rewrites .platform/ and DISPATCHER.md to codex paths,
# normalizes Codex routing semantics, and prepends the Codex routing header.
adapter_translate_dispatcher() {
local src="$1" dst="$2"
[[ -f "$src" ]] || return 0
mkdir -p "$dst"
cp "$src" "$dst/AGENTS.md"
rewrite_codex_paths "$dst/AGENTS.md"
normalize_codex_routing_contract "$dst/AGENTS.md"
_cc_prepend_codex_header "$dst/AGENTS.md"
}
# adapter_translate_references <source_refs_dir> <dest_root>
# Copies *.md into dest_root/.codex/references/, rewriting framework paths
# and normalizing Codex routing semantics.
adapter_translate_references() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local out="$dst/.codex/references"
mkdir -p "$out"
for f in "$src"/*.md; do
[[ -f "$f" ]] || continue
should_include "$f" "$CC_PLATFORM" || continue
local out_file="$out/$(basename "$f")"
cp "$f" "$out/"
rewrite_codex_paths "$out_file"
if [[ "$(basename "$f")" == "codex-cli-compat.md" ]]; then
continue
fi
normalize_codex_routing_contract "$out_file"
done
}
# adapter_translate_skills <source_skills_dir> <dest_root>
# Copies each skill directory into dest_root/.agents/skills/<name>/, preserving
# optional subdirectories and rewriting text content for Codex-native output.
_cc_rewrite_skill_markdown() {
local skill_name="$1" file="$2"
[[ -f "$file" ]] || return 0
rewrite_codex_paths "$file"
rewrite_tool_compat "$file"
case "$skill_name" in
onboarding)
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 <name>
# Emits the TOML table key for [mcp_servers.<name>].
# Safe identifiers can be emitted bare; names with spaces or punctuation must
# be quoted and escaped so the original MCP server name is preserved.
_cc_toml_quote_key() {
local name="$1"
if [[ "$name" =~ ^[A-Za-z0-9_-]+$ ]]; then
printf '[mcp_servers.%s]' "$name"
else
local escaped; escaped="$(_cc_toml_escape_string "$name")"
printf '[mcp_servers."%s"]' "$escaped"
fi
}
# _cc_toml_escape_string <value>
# 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 <source_mcp_dir> <dest_root>
# Reads mcp/servers.yaml and writes dest_root/.codex/config.toml.
#
# Config structure:
# - Safe baseline Codex settings at the top
# - [mcp_servers.<name>] tables for each server
# - HTTP servers: url = "..." (plus env inline table)
# - Local servers: command = ["...", "..."] (plus env inline table)
#
# Security (T-01-01): TOML table names with spaces/special chars are quoted.
# Security (T-01-02): Safe approval_policy and sandbox_mode are always emitted.
adapter_translate_config() {
local src="$1" dst="$2"
local yaml="$src/servers.yaml"
[[ -f "$yaml" ]] || return 0
local out_dir="$dst/.codex"
mkdir -p "$out_dir"
local out="$out_dir/config.toml"
{
# ── Safe baseline Codex settings (T-01-02) ──────────────────────────────
echo '# Generated by adapters/codex-cli/adapter.sh — do not edit manually'
echo 'approval_policy = "on-request"'
echo 'sandbox_mode = "workspace-write"'
echo 'sandbox_workspace_write.network_access = false'
echo ''
echo '# Inherit uses the top-level defaults; named profiles override them explicitly.'
echo '[profiles.quality]'
echo 'model = "gpt-5.5"'
echo 'model_reasoning_effort = "high"'
echo ''
echo '[profiles.balanced]'
echo 'model = "gpt-5.5"'
echo 'model_reasoning_effort = "low"'
echo ''
echo '[profiles.budget]'
echo 'model = "gpt-5.5"'
echo 'model_reasoning_effort = "low"'
echo ''
echo '[agents]'
echo 'max_depth = 1'
echo ''
# ── Parse servers.yaml line-by-line and emit TOML tables ────────────────
local current_name="" current_type="" current_url="" current_cmd="" current_env=""
local in_servers=0
_cc_flush_server() {
[[ -z "$current_name" ]] && return 0
local key; key="$(_cc_toml_quote_key "$current_name")"
echo "$key"
if [[ "$current_type" == "local" || ( -z "$current_type" && -n "$current_cmd" ) ]]; then
# Local server: command = ["arg1", "arg2", ...]
# current_cmd is the raw YAML array content e.g. npx, -y, "@anthropic-ai/gmail-mcp"
local cmd_toml
cmd_toml="$(_cc_build_command_array "$current_cmd")"
echo "command = $cmd_toml"
else
# HTTP server: url = "..."
local url_escaped; url_escaped="$(_cc_toml_escape_string "$current_url")"
echo "url = \"$url_escaped\""
fi
# env is only valid for command-based (local) servers; omit for HTTP servers
if [[ "$current_type" == "local" || ( -z "$current_type" && -n "$current_cmd" ) ]]; then
if [[ -n "$current_env" ]]; then
echo "env = $(_cc_build_env_table "$current_env")"
fi
fi
echo ''
}
_cc_build_command_array() {
# Convert a comma-separated YAML sequence payload (without brackets) into
# a TOML array of quoted strings: ["npx", "-y", "@anthropic-ai/gmail-mcp"]
local raw="$1"
local items=()
IFS=',' read -ra parts <<< "$raw"
for part in "${parts[@]}"; do
# Trim leading/trailing whitespace and surrounding quotes
part="${part#"${part%%[![:space:]]*}"}"
part="${part%"${part##*[![:space:]]}"}"
part="${part#\"}" ; part="${part%\"}"
part="${part#\'}" ; part="${part%\'}"
items+=("$part")
done
# Build TOML array
local result='['
local first=1
for item in "${items[@]}"; do
[[ -z "$item" ]] && continue
local escaped; escaped="$(_cc_toml_escape_string "$item")"
[[ $first -eq 1 ]] && first=0 || result+=', '
result+="\"$escaped\""
done
result+=']'
echo "$result"
}
_cc_build_env_table() {
local raw="$1"
local result='{'
local first=1
IFS=',' read -ra pairs <<< "$raw"
for pair in "${pairs[@]}"; do
pair="${pair#"${pair%%[![:space:]]*}"}"
pair="${pair%"${pair##*[![:space:]]}"}"
[[ -z "$pair" ]] && continue
local key="${pair%%:*}"
local value="${pair#*:}"
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
key="${key#\"}" ; key="${key%\"}"
value="${value#\"}" ; value="${value%\"}"
local escaped_key escaped_value
escaped_key="$(_cc_toml_escape_string "$key")"
escaped_value="$(_cc_toml_escape_string "$value")"
[[ $first -eq 1 ]] && first=0 || result+=', '
result+="\"$escaped_key\" = \"$escaped_value\""
done
result+='}'
echo "$result"
}
while IFS= read -r line; do
line="${line%$'\r'}"
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line//[[:space:]]/}" ]] && continue
if [[ "$line" =~ ^[[:space:]]*servers:[[:space:]]*$ ]]; then
in_servers=1
elif [[ "$line" =~ ^[[:space:]]*-[[:space:]]name:[[:space:]]*(.+)$ ]]; then
[[ $in_servers -eq 1 ]] && _cc_flush_server
current_name="$(printf '%s' "${BASH_REMATCH[1]}" | tr -d '"')"
current_type=""
current_url=""
current_cmd=""
current_env=""
elif [[ "$line" =~ ^[[:space:]]{4}type:[[:space:]]*(.+)$ ]]; then
current_type="$(printf '%s' "${BASH_REMATCH[1]}" | tr -d '"')"
elif [[ "$line" =~ ^[[:space:]]{4}url:[[:space:]]*(.+)$ ]]; then
current_url="$(printf '%s' "${BASH_REMATCH[1]}" | tr -d '"')"
elif [[ "$line" =~ ^[[:space:]]{4}command:[[:space:]]*\[(.*)\][[:space:]]*$ ]]; then
# Extract the array payload between [ and ]
current_cmd="${BASH_REMATCH[1]}"
elif [[ "$line" =~ ^[[:space:]]{4}env:[[:space:]]*\{(.*)\}[[:space:]]*$ ]]; then
current_env="${BASH_REMATCH[1]}"
fi
done < "$yaml"
_cc_flush_server
} > "$out"
}
# _cc_extract_description <agent_file>
# Extracts the description value from YAML frontmatter, handling both:
# - Scalar: description: Some text
# - Folded block (description: >): captures all indented continuation lines,
# joins them with spaces, and trims the result.
# Emits the description as a single-line string (no leading/trailing whitespace).
_cc_extract_description() {
local file="$1"
awk '
{ sub(/\r$/, "") }
/^---$/ { fm++; next }
fm == 1 && /^description:[[:space:]]*>/ {
# Folded-block style: value starts on next indented lines
in_desc=1
next
}
fm == 1 && /^description:/ {
# Scalar style: description: value on same line
sub(/^description:[[:space:]]*/, "")
desc=$0
in_desc=0
next
}
fm == 1 && in_desc && /^[[:space:]]/ {
# Continuation line (indented)
sub(/^[[:space:]]+/, "")
if (desc == "") desc=$0
else desc=desc " " $0
next
}
fm == 1 && in_desc && !/^[[:space:]]/ {
# End of folded block
in_desc=0
}
fm >= 2 { exit }
END {
# Trim trailing whitespace/period added by folded-block joining
gsub(/[[:space:]]+$/, "", desc)
print desc
}
' "$file"
}
# _cc_toml_escape_dquote_string <value>
# Escapes a string for embedding in a TOML double-quoted basic string.
# Handles backslash and double-quote characters.
_cc_toml_escape_dquote_string() {
local val="$1"
val="${val//\\/\\\\}"
val="${val//\"/\\\"}"
printf '%s' "$val"
}
# _cc_model_to_codex <model>
# Maps the source model tier into a Codex model id.
_cc_model_to_codex() {
local model="$1"
case "$model" in
gpt-5.4|gpt-5.4-mini|gpt-5.3-codex-spark) echo "gpt-5.5" ;;
*/*|gpt-*) echo "$model" ;;
low|mid|high) echo "gpt-5.5" ;;
*) echo "$model" ;;
esac
}
# _cc_model_reasoning_effort <model>
# Maps the source model tier into a Codex reasoning effort.
_cc_model_reasoning_effort() {
local model="$1"
case "$model" in
high|gpt-5.4) echo "high" ;;
low|mid|gpt-5.4-mini|gpt-5.3-codex-spark) echo "low" ;;
*) echo "medium" ;;
esac
}
# _cc_capabilities_to_sandbox <space-separated capabilities>
# Preserves the source intent by only granting workspace writes to agents that
# explicitly declare write/edit/bash capabilities.
_cc_capabilities_to_sandbox() {
local caps="$1"
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 <agent_file> <dest_root>
# Translates a single agent .md file to a Codex CLI custom agent TOML file at:
# <dest_root>/.codex/agents/<basename-without-md>.toml
#
# The TOML file contains:
# name = "<name>"
# description = "<single-line description>"
# developer_instructions = '''
# <body with platform paths rewritten>
# '''
#
# Security (T-01-04): Uses TOML multiline literal strings (''') to avoid
# backslash-escape pitfalls. Falls back to multiline basic strings (""") if
# the body contains the ''' delimiter sequence, escaping as needed.
adapter_translate_agent_toml() {
local agent_file="$1" dst_root="$2"
[[ -f "$agent_file" ]] || return 0
local name; name="$(parse_frontmatter "$agent_file" name)"
[[ -z "$name" ]] && name="$(basename "$agent_file" .md)"
local description; description="$(_cc_extract_description "$agent_file")"
local desc_escaped; desc_escaped="$(_cc_toml_escape_dquote_string "$description")"
local model_raw; model_raw="$(parse_frontmatter "$agent_file" model)"
local model; model="$(_cc_model_to_codex "$model_raw")"
local model_reasoning_effort; model_reasoning_effort="$(_cc_model_reasoning_effort "$model_raw")"
local capabilities; capabilities="$(parse_capabilities "$agent_file")"
local sandbox_mode; sandbox_mode="$(_cc_capabilities_to_sandbox "$capabilities")"
# Get the agent body, apply Codex path rewrites, and then neutralize tool naming.
local body; body="$(agent_body "$agent_file")"
local _body_tmp; _body_tmp="$(mktemp)"
printf '%s\n' "$body" > "$_body_tmp"
rewrite_codex_paths "$_body_tmp"
rewrite_tool_compat "$_body_tmp"
body="$(cat "$_body_tmp")"
rm -f "$_body_tmp"
local out_dir="$dst_root/.codex/agents"
mkdir -p "$out_dir"
local out_file="$out_dir/${name}.toml"
# Choose multiline literal (''') vs basic (""") based on body content (T-01-04)
if [[ "$body" == *"'''"* ]]; then
# Fallback: use multiline basic string, escape backslashes and double-quotes
local escaped_body; escaped_body="${body//\\/\\\\}"
escaped_body="${escaped_body//\"/\\\"}"
{
printf 'name = "%s"\n' "$(_cc_toml_escape_dquote_string "$name")"
printf 'description = "%s"\n' "$desc_escaped"
printf 'model = "%s"\n' "$(_cc_toml_escape_dquote_string "$model")"
printf 'model_reasoning_effort = "%s"\n' "$model_reasoning_effort"
printf 'sandbox_mode = "%s"\n' "$sandbox_mode"
printf 'developer_instructions = """\n'
printf '%s\n' "$escaped_body"
printf '"""\n'
} > "$out_file"
else
# Preferred: multiline literal string — no escaping needed
{
printf 'name = "%s"\n' "$(_cc_toml_escape_dquote_string "$name")"
printf 'description = "%s"\n' "$desc_escaped"
printf 'model = "%s"\n' "$(_cc_toml_escape_dquote_string "$model")"
printf 'model_reasoning_effort = "%s"\n' "$model_reasoning_effort"
printf 'sandbox_mode = "%s"\n' "$sandbox_mode"
printf "developer_instructions = '''\n"
printf '%s\n' "$body"
printf "'''\n"
} > "$out_file"
fi
}
# adapter_translate_agents_toml <source_agents_dir> <dest_root>
# Iterates over all *.md agent files and calls adapter_translate_agent_toml for each.
adapter_translate_agents_toml() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
while IFS= read -r agent; do
[[ -f "$agent" ]] || continue
should_include "$agent" "$CC_PLATFORM" || continue
adapter_translate_agent_toml "$agent" "$dst"
done < <(enumerate_agents "$src")
}
# adapter_build <source_dir> <dest_dir>
# The single entry point invoked by scripts/build.sh.
# Writes into $DIST_DIR/codex-cli/ (dest_dir is already platform-scoped).
adapter_build() {
local src="$1" dst="$2"
local OUT_DIR="$dst"
# Delete and recreate the platform output directory (scoped; T-01-03 accepted)
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
adapter_translate_dispatcher "$src/DISPATCHER.md" "$OUT_DIR"
adapter_translate_references "$src/references" "$OUT_DIR"
adapter_translate_skills "$src/skills" "$OUT_DIR"
adapter_translate_config "$src/mcp" "$OUT_DIR"
adapter_translate_agents_toml "$src/agents" "$OUT_DIR"
}
@@ -0,0 +1,318 @@
#!/usr/bin/env bash
# =============================================================================
# adapters/gemini-cli/adapter.sh — Gemini CLI framework adapter
# =============================================================================
# Sourced by scripts/build.sh AFTER adapters/lib.sh.
# Translates source files into a dist/gemini-cli/ tree that mirrors what
# Gemini CLI expects in the user's project.
# =============================================================================
GEMINI_PLATFORM="gemini-cli"
GEMINI_FW_DIR="gemini"
GEMINI_DISPATCHER="GEMINI.md"
# Capability → Gemini CLI tool names. Returns space-separated tool names.
gemini_capability_to_tools() {
local cap="$1"
case "$cap" in
read) echo "read_file list_directory grep_search" ;;
write) echo "write_file" ;;
edit) echo "replace" ;;
bash) echo "run_shell_command" ;;
webfetch) echo "web_fetch" ;;
websearch) echo "web_search" ;;
notebook) echo "" ;;
task) echo "activate_skill" ;;
todo) echo "" ;;
*) echo "" ;;
esac
}
# Event vocabulary → Gemini CLI native event name.
gemini_event_to_native() {
local event="$1"
case "$event" in
before-tool-use) echo "BeforeTool" ;;
after-tool-use) echo "AfterTool" ;;
on-notification) echo "Notification" ;;
on-session-start) echo "SessionStart" ;;
on-prompt-submit) echo "BeforeAgent" ;;
*) echo "" ;;
esac
}
# Neutral model tier → Gemini model name.
gemini_model_to_native() {
local model="$1"
case "$model" in
*/*) echo "$model" ;;
low) echo "gemini-2.5-flash" ;;
mid) echo "gemini-2.5-flash" ;;
high) echo "gemini-2.5-pro" ;;
*) echo "$model" ;;
esac
}
# Match-tool token → Gemini tool name for hook matchers.
gemini_match_tool_to_native() {
local token="$1"
case "$token" in
read) echo "read_file" ;;
write) echo "write_file" ;;
edit) echo "replace" ;;
bash) echo "run_shell_command" ;;
*) echo "$token" ;;
esac
}
# adapter_translate_dispatcher <source_dispatcher_md> <dest_dir>
# Copies the source DISPATCHER.md to dest_dir/GEMINI.md (no content change).
adapter_translate_dispatcher() {
local src="$1" dst="$2"
[[ -f "$src" ]] || return 0
mkdir -p "$dst"
cp "$src" "$dst/$GEMINI_DISPATCHER"
rewrite_platform_paths "$dst/$GEMINI_DISPATCHER" "$GEMINI_FW_DIR" "$GEMINI_DISPATCHER"
}
# adapter_translate_references <source_refs_dir> <dest_root>
adapter_translate_references() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local out="$dst/.$GEMINI_FW_DIR/references"
mkdir -p "$out"
for f in "$src"/*.md; do
[[ -f "$f" ]] || continue
should_include "$f" "$GEMINI_PLATFORM" || continue
cp "$f" "$out/"
rewrite_platform_paths "$out/$(basename "$f")" "$GEMINI_FW_DIR" "$GEMINI_DISPATCHER"
done
}
# adapter_translate_skills <source_skills_dir> <dest_root>
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" "$GEMINI_PLATFORM" || continue
local name; name="$(basename "$skill_dir")"
local out="$dst/.$GEMINI_FW_DIR/skills/$name"
mkdir -p "$out"
cp "${skill_dir}SKILL.md" "$out/SKILL.md"
rewrite_platform_paths "$out/SKILL.md" "$GEMINI_FW_DIR" "$GEMINI_DISPATCHER"
done
}
# adapter_translate_agents <source_agents_dir> <dest_root>
adapter_translate_agents() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local out_dir="$dst/.$GEMINI_FW_DIR/agents"
mkdir -p "$out_dir"
while IFS= read -r agent; do
[[ -f "$agent" ]] || continue
should_include "$agent" "$GEMINI_PLATFORM" || continue
local name; name="$(parse_frontmatter "$agent" name)"
local model_raw; model_raw="$(parse_frontmatter "$agent" model)"
local caps; caps="$(parse_capabilities "$agent")"
local model_out; model_out="$(gemini_model_to_native "$model_raw")"
# Build deduplicated tools list
local tools_seen="" tools_yaml=""
for cap in $caps; do
local expansion; expansion="$(gemini_capability_to_tools "$cap")"
for tool in $expansion; do
[[ -z "$tool" ]] && continue
case " $tools_seen " in
*" $tool "*) ;;
*)
tools_seen="$tools_seen $tool"
tools_yaml="${tools_yaml} - ${tool}
"
;;
esac
done
done
local out_file="$out_dir/$(basename "$agent")"
{
echo "---"
echo "name: $name"
# Copy description block (may be folded YAML)
awk '{ sub(/\r$/, "") } /^---$/{n++; next} n==1 && /^description:/{print; in_desc=1; next} n==1 && in_desc && /^[[:space:]]/{print; next} n==1 && in_desc && !/^[[:space:]]/{in_desc=0} n>=2{exit}' "$agent"
echo "tools:"
printf '%s' "$tools_yaml"
echo "model: $model_out"
echo "---"
agent_body "$agent"
} > "$out_file"
rewrite_platform_paths "$out_file" "$GEMINI_FW_DIR" "$GEMINI_DISPATCHER"
done < <(enumerate_agents "$src")
}
# adapter_translate_hooks <source_hooks_dir> <dest_root>
adapter_translate_hooks() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local hooks_out="$dst/.$GEMINI_FW_DIR/hooks"
mkdir -p "$hooks_out"
local tpl_dir; tpl_dir="$(dirname "${BASH_SOURCE[0]}")/templates"
local hooks_json='{}'
local have_any=0
while IFS= read -r yaml; do
[[ -f "$yaml" ]] || continue
should_include "$yaml" "$GEMINI_PLATFORM" || continue
local meta; meta="$(parse_hook_yaml "$yaml")"
local hook_name; hook_name="$(echo "$meta" | grep '^name=' | head -1 | cut -d= -f2-)"
local script; script="$(echo "$meta" | grep '^script=' | head -1 | cut -d= -f2-)"
local event; event="$(echo "$meta" | grep '^event=' | head -1 | cut -d= -f2-)"
local match_tool; match_tool="$(echo "$meta" | grep '^match-tool=' | head -1 | cut -d= -f2- || true)"
[[ -f "$src/$script" ]] || continue
# Copy the hook script
cp "$src/$script" "$hooks_out/$script"
chmod +x "$hooks_out/$script"
rewrite_platform_paths "$hooks_out/$script" "$GEMINI_FW_DIR" "$GEMINI_DISPATCHER"
# Generate wrapper script from template
local wrapper_name="${hook_name}-wrapper.sh"
sed -e "s/__HOOK_NAME__/$hook_name/g" -e "s/__EVENT_NAME__/$event/g" \
"$tpl_dir/gemini-hook-wrapper.sh.tmpl" > "$hooks_out/$wrapper_name"
chmod +x "$hooks_out/$wrapper_name"
# Build matcher: map each match-tool token to Gemini tool name
local gemini_event; gemini_event="$(gemini_event_to_native "$event")"
local matcher=""
if [[ -n "$match_tool" ]]; then
for t in $match_tool; do
local native; native="$(gemini_match_tool_to_native "$t")"
if [[ -z "$matcher" ]]; then
matcher="$native"
else
matcher="$matcher|$native"
fi
done
fi
# Add to hooks JSON
local hook_cmd="bash .$GEMINI_FW_DIR/hooks/$wrapper_name"
if [[ -n "$matcher" ]]; then
hooks_json="$(echo "$hooks_json" | jq \
--arg ev "$gemini_event" \
--arg matcher "$matcher" \
--arg cmd "$hook_cmd" \
'.hooks[$ev] += [{ matcher: $matcher, hooks: [{ type: "command", command: $cmd, timeout: 5000 }] }]')"
else
hooks_json="$(echo "$hooks_json" | jq \
--arg ev "$gemini_event" \
--arg cmd "$hook_cmd" \
'.hooks[$ev] += [{ hooks: [{ type: "command", command: $cmd, timeout: 5000 }] }]')"
fi
have_any=1
done < <(enumerate_hooks "$src")
if [[ $have_any -eq 0 ]]; then
rmdir "$hooks_out" 2>/dev/null || true
return 0
fi
echo "$hooks_json" | jq '.' > "$dst/.$GEMINI_FW_DIR/_hooks.json"
}
# adapter_translate_mcp <source_mcp_dir> <dest_root>
adapter_translate_mcp() {
local src="$1" dst="$2"
local yaml="$src/servers.yaml"
[[ -f "$yaml" ]] || return 0
mkdir -p "$dst/.$GEMINI_FW_DIR"
local json='{}'
local current_name="" current_cmd="" current_url="" current_type=""
_gemini_flush_mcp() {
[[ -z "$current_name" ]] && return 0
if [[ "$current_type" == "local" || -n "$current_cmd" ]]; then
local cmd_first; cmd_first="$(echo "$current_cmd" | awk '{print $1}')"
local cmd_rest; cmd_rest="$(echo "$current_cmd" | awk '{$1=""; print}' | sed 's/^ *//')"
local args_json; args_json="$(echo "$cmd_rest" | jq -R 'split(" ") | map(select(length > 0))')"
json="$(echo "$json" | jq --arg n "$current_name" --arg c "$cmd_first" --argjson a "$args_json" \
'.mcpServers[$n] = {command: $c, args: $a}')"
else
json="$(echo "$json" | jq --arg n "$current_name" --arg u "$current_url" \
'.mcpServers[$n] = {url: $u}')"
fi
}
while IFS= read -r line; do
case "$line" in
*"- name:"*)
_gemini_flush_mcp
current_name="$(echo "$line" | sed 's/.*- name:[[:space:]]*//' | tr -d '"')"
current_cmd="" ; current_url="" ; current_type=""
;;
*"type:"*)
current_type="$(echo "$line" | sed 's/.*type:[[:space:]]*//' | tr -d '"')"
;;
*"command:"*"["*)
current_cmd="$(echo "$line" | sed 's/.*command:[[:space:]]*\[//' | sed 's/\][[:space:]]*$//' | tr -d '"' | sed 's/,[[:space:]]*/ /g')"
;;
*"url:"*)
current_url="$(echo "$line" | sed 's/.*url:[[:space:]]*//' | tr -d '"')"
;;
esac
done < "$yaml"
_gemini_flush_mcp
echo "$json" | jq '.' > "$dst/.$GEMINI_FW_DIR/_mcp.json"
unset -f _gemini_flush_mcp
}
# adapter_finalize <source_root> <dest_root>
adapter_finalize() {
local src="$1" dst="$2"
local gemini_dir="$dst/.$GEMINI_FW_DIR"
local hooks_tmp="$gemini_dir/_hooks.json"
local mcp_tmp="$gemini_dir/_mcp.json"
local settings="$gemini_dir/settings.json"
local result='{}'
if [[ -f "$hooks_tmp" ]]; then
result="$(echo "$result" | jq --slurpfile h "$hooks_tmp" '. + $h[0]')"
rm "$hooks_tmp"
fi
if [[ -f "$mcp_tmp" ]]; then
result="$(echo "$result" | jq --slurpfile m "$mcp_tmp" '. + $m[0]')"
rm "$mcp_tmp"
fi
if [[ "$result" != "{}" ]]; then
echo "$result" | jq '.' > "$settings"
fi
}
# adapter_build <source_dir> <dest_dir>
# The single entry point invoked by scripts/build.sh.
adapter_build() {
local src="$1" dst="$2"
rm -rf "$dst"
mkdir -p "$dst"
adapter_translate_dispatcher "$src/DISPATCHER.md" "$dst"
adapter_translate_references "$src/references" "$dst"
adapter_translate_skills "$src/skills" "$dst"
adapter_translate_agents "$src/agents" "$dst"
adapter_translate_hooks "$src/hooks" "$dst"
adapter_translate_mcp "$src/mcp" "$dst"
adapter_finalize "$src" "$dst"
}
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# =============================================================================
# Generated by adapters/gemini-cli/adapter.sh — do not edit.
# Wrapper for hook: __HOOK_NAME__
# Reads Gemini CLI native JSON from stdin, translates it into the neutral
# schema, and pipes the result to __HOOK_NAME__.sh.
# =============================================================================
set -eo pipefail
INPUT=$(cat)
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NEUTRAL=$(echo "$INPUT" | jq -c '{
event: "__EVENT_NAME__",
tool: (.tool_name // ""),
args: (.tool_input // {}),
session_id: (env.GEMINI_SESSION_ID // ""),
cwd: (env.GEMINI_CWD // env.GEMINI_PROJECT_DIR // ""),
framework: "gemini-cli",
platform_dir: ".gemini",
dispatcher_name: "GEMINI.md"
}')
echo "$NEUTRAL" | bash "$HOOK_DIR/__HOOK_NAME__.sh"
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# =============================================================================
# adapters/lib.sh — Shared helpers for framework adapters
# =============================================================================
# Sourced by scripts/build.sh BEFORE the framework-specific adapter.sh.
# Provides vocabulary tables, parsing helpers, filtering, and JSON utilities.
# Do NOT execute directly.
# =============================================================================
# ── Vocabulary constants ─────────────────────────────────────────────────────
# The closed set of capabilities the source frontmatter may declare.
# Adapters consult this list to validate frontmatter before translating.
CAPABILITY_VOCAB="read write edit bash webfetch websearch notebook task todo"
# The closed set of hook event names the source .hook.yaml may declare.
EVENT_VOCAB="before-tool-use after-tool-use on-notification on-session-start on-prompt-submit"
# The closed set of model names the source agents may declare.
MODEL_VOCAB="low mid high"
# ── Path rewriting ───────────────────────────────────────────────────────────
# rewrite_platform_paths <file> <platform_dir> <dispatcher_name>
# Rewrites platform-neutral path references in a text file.
# Source files use .platform/ and DISPATCHER.md as neutral placeholders.
# Each adapter calls this after copying any text file from source to dist,
# passing its platform-specific directory name and dispatcher filename.
rewrite_platform_paths() {
local file="$1" platform_dir="$2" dispatcher="$3"
local tmp; tmp="$(mktemp)"
sed "s|\.platform/|.${platform_dir}/|g; s|DISPATCHER\.md|${dispatcher}|g" "$file" > "$tmp"
mv "$tmp" "$file"
}
# ── Parsing helpers ──────────────────────────────────────────────────────────
# parse_frontmatter <file> <key>
# Echoes the value of a top-level YAML key from the file's --- ... --- block.
# Returns nothing (empty) if the key is not found.
# Supports scalar values and flat list values; preserves the raw value as-written.
parse_frontmatter() {
local file="$1" key="$2"
awk -v key="$key" '
{ sub(/\r$/, "") }
/^---$/ { fm++; next }
fm == 1 {
# Match "key: value" — strip leading whitespace, capture value after first ":"
sub(/^[[:space:]]+/, "")
if (match($0, "^" key ":[[:space:]]*")) {
value = substr($0, RLENGTH + 1)
sub(/[[:space:]]+$/, "", value)
print value
exit
}
}
fm >= 2 { exit }
' "$file"
}
# parse_capabilities <agent_file>
# Echoes the agent's capabilities as space-separated tokens.
# Empty output if capabilities is missing or [].
parse_capabilities() {
local file="$1"
local raw; raw="$(parse_frontmatter "$file" capabilities)"
# Strip [ and ] and commas, leaving space-separated tokens
echo "$raw" | tr -d '[]' | tr ',' ' ' | xargs
}
# should_include <component_file> <framework>
# Exit 0 if the component should be included in the given framework's build.
# Exit 1 if the component's exclude: list contains the framework.
# Supports both frontmatter-delimited files (agents, skills) and plain YAML
# files (hook .yaml) — falls back to a direct key read if no frontmatter found.
should_include() {
local file="$1" framework="$2"
local raw; raw="$(parse_frontmatter "$file" exclude)"
# If parse_frontmatter returned nothing, try reading exclude: as a plain YAML key
# (hook .yaml files don't have --- delimiters)
if [[ -z "$raw" ]]; then
raw="$(awk '/^exclude:/ { sub(/^exclude:[[:space:]]*/, ""); print; exit }' "$file")"
fi
# Treat missing or empty exclude as "include"
[[ -z "$raw" || "$raw" == "[]" ]] && return 0
# Tokenize the list and check membership
local tokens; tokens="$(echo "$raw" | tr -d '[]' | tr ',' ' ' | xargs)"
for t in $tokens; do
[[ "$t" == "$framework" ]] && return 1
done
return 0
}
# parse_hook_yaml <hook_yaml_file>
# Emits key=value lines for each top-level scalar AND for trigger fields.
# Output keys: name, script, event, match-tool (space-separated tokens), exclude.
#
# NOTE: This flattens all triggers into a single stream. When a hook has
# multiple triggers, the association between event and match-tool is lost.
# Currently all hooks use a single trigger, so this is not an issue in
# practice. If multi-trigger hooks are needed, this output format must be
# changed to emit delimited records (one per trigger).
parse_hook_yaml() {
local file="$1"
awk '
/^name:/ { sub(/^name:[[:space:]]*/, ""); print "name=" $0 }
/^script:/ { sub(/^script:[[:space:]]*/, ""); print "script=" $0 }
/^[[:space:]]*-[[:space:]]*event:/ {
sub(/^[[:space:]]*-[[:space:]]*event:[[:space:]]*/, "")
print "event=" $0
}
/^[[:space:]]*match-tool:/ {
sub(/^[[:space:]]*match-tool:[[:space:]]*/, "")
gsub(/[\[\],]/, " ")
gsub(/^[[:space:]]+|[[:space:]]+$/, "")
gsub(/[[:space:]]+/, " ")
print "match-tool=" $0
}
/^exclude:/ { sub(/^exclude:[[:space:]]*/, ""); print "exclude=" $0 }
' "$file"
}
# agent_body <agent_file>
# Echoes everything after the closing --- of the frontmatter block.
agent_body() {
local file="$1"
awk '
{ sub(/\r$/, "") }
fm < 2 && /^---$/ { fm++; next }
fm >= 2 { print }
' "$file"
}
# enumerate_agents <dir>
# Echoes one agent file path per line.
enumerate_agents() {
local dir="$1"
[[ -d "$dir" ]] || return 0
for f in "$dir"/*.md; do
[[ -f "$f" ]] && echo "$f"
done
}
# enumerate_hooks <dir>
# Echoes one .hook.yaml file path per line.
enumerate_hooks() {
local dir="$1"
[[ -d "$dir" ]] || return 0
for f in "$dir"/*.hook.yaml; do
[[ -f "$f" ]] && echo "$f"
done
}
@@ -0,0 +1,353 @@
#!/usr/bin/env bash
# =============================================================================
# adapters/opencode/adapter.sh — Opencode framework adapter
# =============================================================================
# Sourced by scripts/build.sh AFTER adapters/lib.sh.
# Translates source files into a dist/opencode/ tree that mirrors what
# opencode expects in the user's vault.
# =============================================================================
# shellcheck source=adapters/opencode/config-merge.sh
source "$(dirname "${BASH_SOURCE[0]}")/config-merge.sh"
OC_PLATFORM="opencode"
OC_FW_DIR="opencode"
OC_DISPATCHER="AGENTS.md"
# Capability → opencode permission key. Returns the permission key to set to
# "allow" for each capability, or empty string for capabilities that have no
# opencode equivalent (they are dropped).
#
# Reference (spec §"Capability vocabulary"):
# read → implicit, no permission needed
# write → edit: allow
# edit → edit: allow
# bash → bash: allow
# webfetch → webfetch: allow
# websearch → drop (no equivalent)
# notebook → drop
# task → drop (subagent invocation, not a permission)
# todo → drop
oc_capability_to_permission() {
local cap="$1"
case "$cap" in
read) echo "" ;; # implicit
write) echo "edit" ;;
edit) echo "edit" ;;
bash) echo "bash" ;;
webfetch) echo "webfetch" ;;
websearch) echo "" ;; # drop
notebook) echo "" ;; # drop
task) echo "" ;; # drop
todo) echo "" ;; # drop
*) echo "" ;;
esac
}
# Event vocabulary → opencode native event name.
oc_event_to_native() {
local event="$1"
case "$event" in
before-tool-use) echo "tool.execute.before" ;;
after-tool-use) echo "tool.execute.after" ;;
on-notification) echo "session.idle" ;;
on-session-start) echo "session.created" ;;
on-prompt-submit) echo "tui.prompt.append" ;;
*) echo "" ;;
esac
}
# Neutral model tier → opencode provider/model id. Conservative mapping:
# if the source model is already provider-prefixed (contains "/"), pass through
# unchanged. Otherwise look up in the table and fall back to the raw value.
oc_model_to_provider() {
local model="$1"
case "$model" in
*/*) echo "$model" ;; # already qualified
low) echo "anthropic/claude-haiku-4-5" ;;
mid) echo "anthropic/claude-sonnet-4-5" ;;
high) echo "anthropic/claude-opus-4-5" ;;
*) echo "$model" ;; # unknown — passthrough
esac
}
# adapter_translate_dispatcher <source_dispatcher_md> <dest_dir>
# Copies the source DISPATCHER.md to dest_dir/AGENTS.md (opencode's vault-root
# dispatcher filename). Rewrites .platform/ and DISPATCHER.md to opencode paths.
adapter_translate_dispatcher() {
local src="$1" dst="$2"
[[ -f "$src" ]] || return 0
mkdir -p "$dst"
cp "$src" "$dst/AGENTS.md"
rewrite_platform_paths "$dst/AGENTS.md" "$OC_FW_DIR" "$OC_DISPATCHER"
}
# adapter_translate_references <source_refs_dir> <dest_root>
# Copies *.md into dest_root/.opencode/references/, rewriting framework paths.
adapter_translate_references() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local out="$dst/.opencode/references"
mkdir -p "$out"
for f in "$src"/*.md; do
[[ -f "$f" ]] || continue
should_include "$f" "$OC_PLATFORM" || continue
cp "$f" "$out/"
rewrite_platform_paths "$out/$(basename "$f")" "$OC_FW_DIR" "$OC_DISPATCHER"
done
}
# adapter_translate_skills <source_skills_dir> <dest_root>
# Copies each skill directory's SKILL.md into dest_root/.opencode/skills/<name>/,
# rewriting framework paths in the body.
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" "$OC_PLATFORM" || continue
local name; name="$(basename "$skill_dir")"
local out="$dst/.opencode/skills/$name"
mkdir -p "$out"
cp "${skill_dir}SKILL.md" "$out/SKILL.md"
rewrite_platform_paths "$out/SKILL.md" "$OC_FW_DIR" "$OC_DISPATCHER"
done
}
# adapter_translate_agents <source_agents_dir> <dest_root>
# For each *.md in source_agents_dir, translate the capabilities frontmatter
# into an opencode permission block, map the model, and write to
# dest_root/.opencode/agents/<name>.md.
adapter_translate_agents() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local out_dir="$dst/.opencode/agents"
mkdir -p "$out_dir"
while IFS= read -r agent; do
[[ -f "$agent" ]] || continue
should_include "$agent" "$OC_PLATFORM" || continue
local model_raw; model_raw="$(parse_frontmatter "$agent" model)"
local mode_raw; mode_raw="$(parse_frontmatter "$agent" mode)"
local caps; caps="$(parse_capabilities "$agent")"
local model_out; model_out="$(oc_model_to_provider "$model_raw")"
local mode_out="${mode_raw:-subagent}"
# Build a unique permission list
local perms=""
for cap in $caps; do
local p; p="$(oc_capability_to_permission "$cap")"
[[ -z "$p" ]] && continue
# Dedupe: skip if already in $perms (space-delimited)
case " $perms " in
*" $p "*) ;;
*) perms="$perms $p" ;;
esac
done
perms="${perms# }"
local out_file="$out_dir/$(basename "$agent")"
{
echo "---"
# Copy description block verbatim (may be folded YAML with continuation lines)
awk '{ sub(/\r$/, "") } /^---$/{n++; next} n==1 && /^description:/{print; in_desc=1; next} n==1 && in_desc && /^[[:space:]]/{print; next} n==1 && in_desc && !/^[[:space:]]/{in_desc=0} n>=2{exit}' "$agent"
echo "mode: $mode_out"
echo "model: $model_out"
if [[ -z "$perms" ]]; then
echo "permission: {}"
else
echo "permission:"
for p in $perms; do
echo " $p: allow"
done
fi
echo "---"
echo ""
agent_body "$agent"
} > "$out_file"
rewrite_platform_paths "$out_file" "$OC_FW_DIR" "$OC_DISPATCHER"
done < <(enumerate_agents "$src")
}
# _oc_hook_registry_json <source_hooks_dir>
# Emits a JSON array literal representing the hook registry, suitable for
# substituting into the plugin template. Each entry: {name, script, triggers: [{event, matchTool}]}.
# Script paths are stored as "../hooks/<basename>" so the plugin (at .opencode/plugins/)
# can reach .opencode/hooks/ at runtime via __dirname + path join.
_oc_hook_registry_json() {
local src="$1"
local entries='[]'
while IFS= read -r yaml; do
[[ -f "$yaml" ]] || continue
should_include "$yaml" "$OC_PLATFORM" || continue
local meta; meta="$(parse_hook_yaml "$yaml")"
local name; name="$(echo "$meta" | grep '^name=' | head -1 | cut -d= -f2-)"
local script; script="$(echo "$meta" | grep '^script=' | head -1 | cut -d= -f2-)"
local event; event="$(echo "$meta" | grep '^event=' | head -1 | cut -d= -f2-)"
local match_tool; match_tool="$(echo "$meta" | grep '^match-tool=' | head -1 | cut -d= -f2- || true)"
local oc_event; oc_event="$(oc_event_to_native "$event")"
# Build the matchTool JSON array (empty array if no filter)
local match_json='[]'
if [[ -n "$match_tool" ]]; then
match_json="$(echo "$match_tool" | jq -R 'split(" ") | map(select(length > 0))')"
fi
# Use ../hooks/<script> so the plugin at .opencode/plugins/ can resolve
# its sibling .opencode/hooks/ directory at runtime.
local script_rel="../hooks/$script"
entries="$(echo "$entries" | jq \
--arg name "$name" \
--arg script "$script_rel" \
--arg event "$oc_event" \
--argjson match "$match_json" \
'. += [{name: $name, script: $script, triggers: [{event: $event, matchTool: $match}]}]')"
done < <(enumerate_hooks "$src")
echo "$entries"
}
# adapter_translate_hooks <source_hooks_dir> <dest_root>
# Copies each hook's .sh script to dst/.opencode/hooks/ and generates a single
# dst/.opencode/plugins/mbifc-hooks.js plugin containing the vendored bash
# executor plus a hook registry synthesised from the source .hook.yaml files.
adapter_translate_hooks() {
local src="$1" dst="$2"
[[ -d "$src" ]] || return 0
local hooks_out="$dst/.opencode/hooks"
local plugins_out="$dst/.opencode/plugins"
mkdir -p "$hooks_out" "$plugins_out"
# Copy every referenced .sh script
local have_any=0
while IFS= read -r yaml; do
[[ -f "$yaml" ]] || continue
should_include "$yaml" "$OC_PLATFORM" || continue
local meta; meta="$(parse_hook_yaml "$yaml")"
local script; script="$(echo "$meta" | grep '^script=' | head -1 | cut -d= -f2-)"
[[ -f "$src/$script" ]] || continue
cp "$src/$script" "$hooks_out/$script"
chmod +x "$hooks_out/$script"
rewrite_platform_paths "$hooks_out/$script" "$OC_FW_DIR" "$OC_DISPATCHER"
have_any=1
done < <(enumerate_hooks "$src")
# If there were no hooks, skip plugin generation and clean up the empty dirs
if [[ $have_any -eq 0 ]]; then
rmdir "$hooks_out" "$plugins_out" 2>/dev/null || true
return 0
fi
# Load templates
local tpl_dir; tpl_dir="$(dirname "${BASH_SOURCE[0]}")/templates"
# Build the registry JSON
local registry; registry="$(_oc_hook_registry_json "$src")"
# Pretty-print registry (2-space indent)
local registry_pretty; registry_pretty="$(echo "$registry" | jq '.')"
# Substitute placeholders by reading the template line-by-line.
# We avoid sed/awk gsub because the replacement strings (JS code, JSON)
# contain backslashes and ampersands that break regex replacement.
local out="$plugins_out/mbifc-hooks.js"
local executor_file="$tpl_dir/bash-executor.js"
local stub_file="$tpl_dir/plugin-stub.js.tmpl"
while IFS= read -r line; do
case "$line" in
*__BASH_EXECUTOR__*)
cat "$executor_file"
;;
*__HOOK_REGISTRY__*)
# Replace the placeholder within the line (preserves "const HOOKS = " prefix)
local prefix="${line%%__HOOK_REGISTRY__*}"
local suffix="${line##*__HOOK_REGISTRY__}"
printf '%s' "$prefix"
printf '%s' "$registry_pretty"
printf '%s\n' "$suffix"
;;
*)
printf '%s\n' "$line"
;;
esac
done < "$stub_file" > "$out"
}
# adapter_translate_mcp <source_mcp_dir> <dest_root>
# Reads mcp/servers.yaml and writes dst/opencode.json with a top-level "mcp" key.
# Local servers → {type: "local", command: "<joined>", environment: {}}
# HTTP servers → {type: "remote", url: "..."}
adapter_translate_mcp() {
local src="$1" dst="$2"
local yaml="$src/servers.yaml"
[[ -f "$yaml" ]] || return 0
mkdir -p "$dst"
local out="$dst/opencode.json"
local json='{}'
local current_name="" current_cmd="" current_url="" current_type=""
_oc_flush_current() {
[[ -z "$current_name" ]] && return 0
if [[ "$current_type" == "local" || -n "$current_cmd" ]]; then
local cmd_str="${current_cmd// / }"
json="$(echo "$json" | jq --arg n "$current_name" --arg c "$cmd_str" \
'.[$n] = {type: "local", command: $c, environment: {}}')"
else
json="$(echo "$json" | jq --arg n "$current_name" --arg u "$current_url" \
'.[$n] = {type: "remote", url: $u}')"
fi
}
while IFS= read -r line; do
case "$line" in
*"- name:"*)
_oc_flush_current
current_name="$(echo "$line" | sed 's/.*- name:[[:space:]]*//' | tr -d '"')"
current_cmd=""
current_url=""
current_type=""
;;
*"type:"*)
current_type="$(echo "$line" | sed 's/.*type:[[:space:]]*//' | tr -d '"')"
# Opencode uses "remote" for HTTP; normalise the source "http" → "remote"
[[ "$current_type" == "http" ]] && current_type="remote"
;;
*"command:"*"["*)
current_cmd="$(echo "$line" | sed 's/.*command:[[:space:]]*\[//' | sed 's/\][[:space:]]*$//' | tr -d '"' | sed 's/,[[:space:]]*/ /g')"
;;
*"url:"*)
current_url="$(echo "$line" | sed 's/.*url:[[:space:]]*//' | tr -d '"')"
;;
esac
done < "$yaml"
_oc_flush_current
echo "$json" | jq '{mcp: .}' > "$out"
}
# adapter_finalize <source_root> <dest_root>
# Opencode has no per-framework manifest file; placeholder for future additions.
adapter_finalize() {
local src="$1" dst="$2"
return 0
}
# adapter_build <source_dir> <dest_dir>
# The single entry point invoked by scripts/build.sh.
adapter_build() {
local src="$1" dst="$2"
rm -rf "$dst"
mkdir -p "$dst"
adapter_translate_dispatcher "$src/DISPATCHER.md" "$dst"
adapter_translate_references "$src/references" "$dst"
adapter_translate_skills "$src/skills" "$dst"
adapter_translate_agents "$src/agents" "$dst"
adapter_translate_hooks "$src/hooks" "$dst"
adapter_translate_mcp "$src/mcp" "$dst"
adapter_finalize "$src" "$dst"
}
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# =============================================================================
# adapters/opencode/config-merge.sh — JSON config merge for opencode.json
# =============================================================================
# Merges a built opencode.json with an existing one in the vault, preserving
# user keys and indentation style (2-space, 4-space, or tab). Only the "mcp"
# key is managed; everything else belongs to the user. Note: jq normalizes
# whitespace and may reorder keys within objects.
#
# Sourced by the opencode adapter. Requires jq.
# =============================================================================
# Provide a fallback warn() if scripts/lib.sh has not been sourced yet.
if ! declare -f warn >/dev/null 2>&1; then
warn() { echo " ! $*" >&2; }
fi
# oc_detect_indent <file>
# Detects the indentation unit used in a JSON file.
# Returns the number of spaces (2 or 4). Defaults to 2.
oc_detect_indent() {
local file="$1"
local indent_str; indent_str="$(awk '/^[[:space:]]+[^[:space:]]/ { match($0, /^[[:space:]]+/); print substr($0, 1, RLENGTH); exit }' "$file")"
if [[ -z "$indent_str" ]]; then
echo "2"
return
fi
case "$indent_str" in
$'\t'*) echo "tab" ;;
" "*) echo "4" ;;
*) echo "2" ;;
esac
}
# oc_config_merge <built_file> <existing_file> <output_file>
# Merges built opencode.json into an existing one:
# - If existing doesn't exist → copy built as-is
# - If existing is malformed → overwrite with built (with warning)
# - Otherwise → merge: our mcp entries overwrite same-name, user keys preserved
oc_config_merge() {
local built="$1" existing="$2" output="$3"
# Fresh install — no existing file
if [[ ! -f "$existing" ]]; then
cp "$built" "$output"
return 0
fi
# Validate existing file is JSON
if ! jq empty "$existing" 2>/dev/null; then
warn "Existing opencode.json is malformed — overwriting with built version"
cp "$built" "$output"
return 0
fi
# Detect indentation from existing file
local indent; indent="$(oc_detect_indent "$existing")"
local jq_indent
case "$indent" in
tab) jq_indent="--tab" ;;
4) jq_indent="--indent 4" ;;
*) jq_indent="--indent 2" ;;
esac
# Read our managed MCP entries
local our_mcp; our_mcp="$(jq '.mcp // {}' "$built")"
# Merge: start with existing, overlay our mcp entries.
# Always write to a temp file first to support in-place merges (output == existing).
local tmp; tmp="$(mktemp)"
# shellcheck disable=SC2086
jq $jq_indent --argjson our_mcp "$our_mcp" '
.mcp = ((.mcp // {}) + $our_mcp)
' "$existing" > "$tmp"
mv "$tmp" "$output"
}
@@ -0,0 +1,43 @@
// =============================================================================
// bash-executor.js — Spawn a bash script and pipe neutral JSON context to stdin
// =============================================================================
// This file is vendored verbatim into the generated mbifc-hooks.js plugin at
// build time. It must not require any npm modules beyond Node.js builtins so
// that opencode's embedded runtime can execute it without installation.
// =============================================================================
const { spawn } = require("node:child_process");
/**
* Run a bash script with a JSON payload on stdin.
*
* @param {string} scriptPath Absolute path to the .sh file.
* @param {object} payload Neutral-schema object to pipe in as JSON.
* @param {object} [env] Extra environment variables (merged with process.env).
* @returns {Promise<{exitCode:number, stdout:string, stderr:string}>}
*/
function runBashHook(scriptPath, payload, env = {}) {
return new Promise((resolve, reject) => {
const child = spawn("bash", [scriptPath], {
env: { ...process.env, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (d) => { stdout += d.toString(); });
child.stderr.on("data", (d) => { stderr += d.toString(); });
child.on("error", reject);
child.on("close", (exitCode) => {
resolve({ exitCode: exitCode ?? 0, stdout, stderr });
});
try {
child.stdin.write(JSON.stringify(payload));
child.stdin.end();
} catch (err) {
reject(err);
}
});
}
module.exports = { runBashHook };
@@ -0,0 +1,91 @@
// =============================================================================
// Generated by adapters/opencode/adapter.sh — do not edit.
// mbifc-hooks.js — opencode plugin that dispatches events to bash hook scripts
// =============================================================================
// This file is generated at build time. It contains a vendored bash executor
// (from adapters/opencode/templates/bash-executor.js) and a hook registry
// synthesised from the source hooks/*.hook.yaml files.
// =============================================================================
// ── bash-executor.js (vendored) ─────────────────────────────────────────────
__BASH_EXECUTOR__
// ── Hook registry (generated) ───────────────────────────────────────────────
const HOOKS = __HOOK_REGISTRY__;
// ── Helpers ─────────────────────────────────────────────────────────────────
/**
* Build the neutral JSON payload from an opencode event context.
* The opencode event shapes vary per event; we extract the common fields and
* leave everything else under `args`.
*/
function buildPayload(eventName, input) {
const tool = (input && (input.tool || input.toolName)) || "";
const args =
(input && (input.args || input.toolInput || input.input)) ||
(input && input.title !== undefined ? { title: input.title, message: input.message } : {}) ||
{};
const neutralEvent = (() => {
switch (eventName) {
case "tool.execute.before": return "before-tool-use";
case "tool.execute.after": return "after-tool-use";
case "session.idle": return "on-notification";
case "session.created": return "on-session-start";
case "tui.prompt.append": return "on-prompt-submit";
default: return eventName;
}
})();
return {
event: neutralEvent,
tool,
args,
session_id: (input && input.sessionId) || "",
cwd: (input && input.cwd) || process.cwd(),
framework: "opencode",
platform_dir: ".opencode",
dispatcher_name: "AGENTS.md",
};
}
/**
* Check whether a hook's match-tool filter (if any) matches the current tool.
*/
function matchesTool(hook, tool) {
const m = hook.matchTool;
if (!m || m.length === 0) return true;
return m.includes((tool || "").toLowerCase());
}
// ── Plugin entry point ──────────────────────────────────────────────────────
module.exports = async function mbifcHooksPlugin({ app, client }) {
// Group hooks by opencode event name
const byEvent = {};
for (const h of HOOKS) {
for (const trig of h.triggers) {
(byEvent[trig.event] ||= []).push({ ...h, matchTool: trig.matchTool });
}
}
// Register one handler per opencode event; dispatch to matching hooks.
const handlers = {};
for (const [event, hooks] of Object.entries(byEvent)) {
handlers[event] = async (input, output) => {
const payload = buildPayload(event, { ...input, ...output });
for (const h of hooks) {
if (!matchesTool(h, payload.tool)) continue;
const path = require("node:path");
const scriptAbs = path.isAbsolute(h.script)
? h.script
: path.join(__dirname, h.script);
const { exitCode, stdout, stderr } = await runBashHook(scriptAbs, payload);
if (exitCode === 2) {
// Documented opencode block mechanism for tool.execute.before
const reason = stdout.trim() || stderr.trim() || "exit 2";
throw new Error(`[${h.name}] blocked: ${reason}`);
}
}
};
}
return handlers;
};