vault-sync: 2026-09-16 00:00 (jimmy)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+43
@@ -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 };
|
||||
+91
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user