#!/usr/bin/env bash set -euo pipefail usage() { cat <<'USAGE' 使い方: bash scripts/setup-opencode.sh [--model ID ...] [--no-plugin] [--help] API キーなしで公開モデル一覧を取得し、OpenCode に aichat を登録します。 登録後、opencode auth login で API キーを登録してください。 --model ID 登録するモデルを指定します(省略時は全モデル) --no-plugin plugin の取得・配置・登録を省略します --help, -h このヘルプを表示します USAGE } fail() { printf 'エラー: %s\n' "$1" >&2 exit "${2:-1}" } check_dependencies() { local command_name for command_name in bash curl jq; do if ! command -v "$command_name" >/dev/null 2>&1; then fail "$command_name が見つかりません。Ubuntu/Debian では sudo apt install bash curl jq、macOS では brew install bash curl jq でインストールしてください。" fi done } parse_args() { model_ids=() install_plugin=true while (($#)); do case "$1" in --help|-h) usage; exit 0 ;; --no-plugin) install_plugin=false; shift ;; --model) if (($# < 2)) || [[ ! $2 =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$ ]]; then usage >&2 fail '--model には有効なモデル ID を指定してください。' 2 fi model_ids+=("$2") shift 2 ;; *) usage >&2; fail "不明なオプションです: $1" 2 ;; esac done } # design.md §11.2: API と plugin の通信先に同じ scheme/authority 検証を適用する。 validate_url() { local raw=$1 label=$2 host [[ $raw =~ ^(https?)://([^/?#]+)([^#]*)$ ]] || fail "$label の URL が不正です。" scheme=${BASH_REMATCH[1]} authority=${BASH_REMATCH[2]} url_suffix=${BASH_REMATCH[3]} [[ $authority != *'@'* && $raw != *'\'* && ! $raw =~ [[:space:][:cntrl:]] ]] || fail "$label の URL に認証情報や空白を含めることはできません。" if [[ $authority =~ ^(\[[^]]+\])(:[0-9]+)?$ ]]; then host=${BASH_REMATCH[1]} elif [[ $authority =~ ^([^:]+)(:[0-9]+)?$ ]]; then host=${BASH_REMATCH[1]} else fail "$label の URL のホストまたはポートが不正です。" fi if [[ $scheme == http && $host != localhost && $host != 127.0.0.1 && $host != '[::1]' ]]; then fail "$label は HTTPS にしてください(localhost のみ HTTP 可)。" fi } normalize_base_url() { validate_url "${METACEPTRONS2_API_BASE_URL:-https://api.karudan.info/v1}" 'API の接続先' [[ $url_suffix == '' || $url_suffix == '/' || $url_suffix == '/v1' || $url_suffix == '/v1/' ]] || fail 'API の接続先 URL のパスは /v1 にしてください。' origin="$scheme://$authority" base_url="$origin/v1" models_url="$origin/public/models" curl_protocol=$scheme plugin_path='' plugin_spec='' if [[ $install_plugin == true ]]; then plugin_url=${METACEPTRONS2_PLUGIN_URL:-https://karudan.info/opencode-plugin.js} validate_url "$plugin_url" 'plugin の取得先' plugin_protocol=$scheme plugin_path="${XDG_DATA_HOME:-$HOME/.local/share}/metaceptrons2/opencode-plugin.js" [[ $plugin_path == /* ]] || fail 'plugin の保存先は絶対パスにしてください。' plugin_spec="file://$plugin_path" fi } check_config() { config_path="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json" if [[ -L $config_path ]]; then fail "シンボリックリンクの設定ファイルは変更できません: $config_path" fi if [[ -e $config_path ]]; then [[ -f $config_path ]] || fail "通常ファイル以外の設定は変更できません: $config_path" jq -e 'type == "object"' "$config_path" >/dev/null 2>&1 || fail "既存の設定は厳密な JSON オブジェクトではありません: $config_path" else [[ ! -e ${config_path}c && ! -L ${config_path}c ]] || fail "JSONC 設定が存在します。手動で確認してください: ${config_path}c" fi } cleanup() { [[ -z ${response_file:-} ]] || rm -f -- "$response_file" [[ -z ${plugin_file:-} ]] || rm -f -- "$plugin_file" [[ -z ${output_file:-} ]] || rm -f -- "$output_file" } # design.md §11.2: 書き込み前に保存先を検査し、失敗時は両ファイルを保持する。 check_plugin() { [[ $install_plugin == true ]] || return 0 [[ ! -L $plugin_path ]] || fail "シンボリックリンクの plugin は変更できません: $plugin_path" [[ ! -e $plugin_path || -f $plugin_path ]] || fail "通常ファイル以外の plugin は変更できません: $plugin_path" } fetch_plugin() { [[ $install_plugin == true ]] || return 0 local status size plugin_dir plugin_dir=${plugin_path%/*} mkdir -p -- "$plugin_dir" plugin_file=$(mktemp "$plugin_dir/.opencode-plugin.XXXXXXXX") if ! status=$(curl -q --silent --show-error --output "$plugin_file" --write-out '%{http_code}' \ --max-time 10 --proto "=$plugin_protocol" --max-filesize 2097152 "$plugin_url" 2>/dev/null); then fail 'plugin を取得できませんでした。接続先・ネットワーク・応答サイズを確認してください。' fi [[ $status == 200 ]] || fail "plugin を取得できませんでした(HTTP $status)。" size=$(wc -c < "$plugin_file") ((size > 0 && size <= 2097152)) || fail 'plugin の応答が空か、サイズ上限 2 MiB を超えています。' chmod 0644 "$plugin_file" } place_plugin() { [[ $install_plugin == true ]] || return 0 if [[ -f $plugin_path ]] && cmp -s -- "$plugin_path" "$plugin_file"; then printf 'plugin はすでに最新です: %s\n' "$plugin_path" else mv -f -- "$plugin_file" "$plugin_path" plugin_file='' printf 'plugin を配置しました: %s\n' "$plugin_path" fi } fetch_models() { local status response_file=$(mktemp) if status=$(curl -q --silent --show-error --output "$response_file" --write-out '%{http_code}' \ --max-time 10 --proto "=$curl_protocol" --max-filesize 1048576 "$models_url" 2>/dev/null); then : else fail 'モデル一覧を取得できませんでした。接続先・ネットワーク・応答サイズを確認してください。' fi case "$status" in 200) ;; 404) fail '接続先のサーバーが /public/models に未対応です(404)。gateway を更新するか管理者に問い合わせてください。' ;; 429) fail 'リクエストが制限されました(429)。しばらく待って再実行してください。' ;; *) fail "モデル一覧を取得できませんでした(HTTP $status)。接続先またはサービスの状態を確認してください。" ;; esac jq -e '.data | type == "array"' "$response_file" >/dev/null 2>&1 || fail 'モデル一覧の応答形式が不正です。' model_count=$(jq '.data | length' "$response_file") printf '%s から %s 件のモデルを取得しました\n' "$models_url" "$model_count" check_requested_models } # --model で指定した ID がすべて一覧にあることを確認する check_requested_models() { local missing ((${#model_ids[@]})) || return 0 missing=$(jq -r '[.data[].id] as $ids | $ARGS.positional[] | select(. as $id | $ids | index($id) == null)' \ "$response_file" --args ${model_ids[@]+"${model_ids[@]}"} | paste -sd ' ' -) || fail 'モデル一覧の応答形式が不正です。' [[ -z $missing ]] || fail "指定したモデルは利用できるモデル一覧にありません: $missing" } merge_config() { local config_dir input_path merge_error selected_count existing_count added_count updated_count config_dir=${config_path%/*} mkdir -p -- "$config_dir" # mktemp は mode 600 で作る。既存ファイルがあれば cp -p で権限を引き継ぐ output_file=$(mktemp "$config_dir/.opencode.json.XXXXXXXX") if [[ -e $config_path ]]; then input_path=$config_path cp -p -- "$config_path" "$output_file" else input_path=/dev/stdin fi if ! merge_error=$(jq --slurpfile response "$response_file" --arg base "$base_url" --arg origin "$origin" \ --arg spec "$plugin_spec" --argjson install "$install_plugin" ' def obj($v; $label): if ($v | type) == "object" then $v else error($label + " は JSON オブジェクトである必要があります") end; def valid_id: type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$"); if $install then (if has("plugin") then .plugin else [] end) as $plugins | if ($plugins | type) != "array" then error("plugin は配列である必要があります") else . end | def matches: if type == "string" then . == $spec elif type == "array" then .[0] == $spec else false end; (reduce $plugins[] as $entry ({}; if ($entry | matches) and ($entry | type) == "array" and ($entry | length) > 1 then . + obj($entry[1]; "plugin options") else . end)) as $saved | .plugin = ([$plugins[] | select(matches | not)] + [[$spec, ($saved + {apiBaseUrl: $base, authorizationServerUrl: $origin})]]) else . end | ($response[0].data) as $all | if any($all[]; (.id | valid_id) | not) then error("モデル一覧に不正な ID があります") else . end | ($ARGS.positional) as $requested | if any($requested[]; . as $id | all($all[]; .id != $id)) then error("指定したモデルが一覧にありません") else . end | obj((if has("provider") then .provider else {} end); "provider") as $providers | obj((if $providers | has("aichat") then $providers.aichat else {} end); "provider.aichat") as $current | obj((if $current | has("options") then $current.options else {} end); "provider.aichat.options") as $options | obj((if $current | has("models") then $current.models else {} end); "provider.aichat.models") as $models | (reduce $all[] as $item ($models; if ($requested | length) > 0 and (($requested | index($item.id)) == null) then . else ($item.id) as $id | obj((.[$id] // {}); "モデル") as $old | ($old + {name: (if ($item.name | type) == "string" then $item.name else $id end)}) as $named | (if ($item.context_length != null and $item.max_output_tokens != null) then $named + {limit: {context: $item.context_length, output: $item.max_output_tokens}} else $named end) as $limited | (if ($item | has("input_modalities")) and ($item.input_modalities | type) == "array" then $limited + (if $limited | has("attachment") then {} else {attachment: ($item.input_modalities | index("image") != null)} end) + (if $limited | has("modalities") then {} else {modalities: {input: (if ($item.input_modalities | index("image")) != null then ["text", "image"] else ["text"] end), output: ["text"]}} end) else $limited end) as $capable | .[$id] = (if ($item | has("supports_reasoning")) and ($item.supports_reasoning | type) == "boolean" and ($capable | has("reasoning") | not) then $capable + {reasoning: $item.supports_reasoning} else $capable end) end )) as $merged | . + {"$schema": (if has("$schema") then ."$schema" else "https://opencode.ai/config.json" end), provider: ($providers + {aichat: ($current + { npm: "@ai-sdk/openai-compatible", name: (if $current | has("name") then $current.name else "metaceptrons2" end), options: ($options + {baseURL: $base}), models: $merged })})} ' "$input_path" --args ${model_ids[@]+"${model_ids[@]}"} 2>&1 > "$output_file" <<< '{}'); then merge_error=$(printf '%s' "$merge_error" | sed -e 's/^jq: error ([^)]*): //' | head -n 1) fail "設定のマージに失敗したため、設定ファイルは変更していません: ${merge_error:-不明なエラー}" fi selected_count=$(jq '$ARGS.positional as $ids | [.data[] | select(($ids | length) == 0 or (.id as $id | $ids | index($id) != null))] | length' "$response_file" --args ${model_ids[@]+"${model_ids[@]}"}) existing_count=0 if [[ -e $config_path ]]; then existing_count=$(jq --slurpfile response "$response_file" '$ARGS.positional as $ids | (.provider.aichat.models // {}) as $old | [$response[0].data[] | select(($ids | length) == 0 or (.id as $id | $ids | index($id) != null)) | .id as $id | select($old | has($id))] | length' "$config_path" --args ${model_ids[@]+"${model_ids[@]}"}) fi added_count=$((selected_count - existing_count)) updated_count=$existing_count # design.md §11.2: 取得とマージの成功後に plugin を先に配置する。 # design.md §11.2: 設定が存在しない plugin を参照する状態を避け、設定置換失敗時も旧設定で動けるようにする。 place_plugin if [[ -e $config_path ]] && cmp -s -- "$config_path" "$output_file"; then printf 'すでに最新です: %s\n' "$config_path" return fi mv -f -- "$output_file" "$config_path" output_file='' printf 'aichat を %s に登録しました(追加 %s 件、更新 %s 件)\n' "$config_path" "$added_count" "$updated_count" } main() { check_dependencies parse_args "$@" normalize_base_url check_config check_plugin trap cleanup EXIT trap 'exit 130' INT TERM fetch_models fetch_plugin merge_config if [[ $install_plugin == true ]]; then printf '次に opencode auth login -p aichat -m "metaceptrons API key" で API キーを登録してください。キーは OpenWebUI の設定画面で発行できます。API access を含むプランが必要です。\n登録後、opencode models aichat で確認できます。\n' else printf '次に API キーを登録してください: opencode auth login を実行し、「Other」を選んで provider ID に aichat、続けて API キーを入力します。\n登録後、opencode models aichat で確認できます。\n' fi } main "$@"